diff --git a/src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs b/src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs index 046ee44..3ff2cf3 100644 --- a/src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs +++ b/src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs @@ -18,35 +18,35 @@ internal abstract class DirectoryProviderBase : IDirectoryProvider protected ILogger logger; protected Stream stream; - protected IEnumerable RetrieveDirectoryListing() + protected async Task> RetrieveDirectoryListingAsync() { - string line; - while ( ( line = ReadLine( ftpClient.ControlStream.Encoding ) ) != null ) + var lines = new List(); + using (var reader = new StreamReader(stream, ftpClient.ControlStream.Encoding)) { - logger?.LogDebug( line ); - yield return line; + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + logger?.LogDebug(line); + lines.Add(line); + } } + + return lines; } - protected string ReadLine( Encoding encoding ) + protected async IAsyncEnumerable RetrieveDirectoryListingEnumerableAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if ( encoding == null ) - throw new ArgumentNullException( nameof( encoding ) ); - - var data = new List(); - var buf = new byte[1]; - string line = null; - - while ( stream.Read( buf, 0, buf.Length ) > 0 ) + using (var reader = new StreamReader(stream, ftpClient.ControlStream.Encoding)) { - data.Add( buf[ 0 ] ); - if ( (char) buf[ 0 ] != '\n' ) - continue; - line = encoding.GetString( data.ToArray() ).Trim( '\r', '\n' ); - break; + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + cancellationToken.ThrowIfCancellationRequested(); + logger?.LogDebug(line); + yield return line; + } } - - return line; } public virtual Task> ListAllAsync() @@ -64,17 +64,20 @@ public virtual Task> ListDirectoriesAsync throw new NotImplementedException(); } - public virtual IAsyncEnumerable ListAllEnumerableAsync( CancellationToken cancellationToken = default ) + public virtual IAsyncEnumerable ListAllEnumerableAsync( + CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public virtual IAsyncEnumerable ListFilesEnumerableAsync( CancellationToken cancellationToken = default ) + public virtual IAsyncEnumerable ListFilesEnumerableAsync( + CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public virtual IAsyncEnumerable ListDirectoriesEnumerableAsync( CancellationToken cancellationToken = default ) + public virtual IAsyncEnumerable ListDirectoriesEnumerableAsync( + CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs b/src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs index 47948fb..816eeac 100644 --- a/src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs +++ b/src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs @@ -15,7 +15,7 @@ internal class ListDirectoryProvider : DirectoryProviderBase { private readonly List directoryParsers; - public ListDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration ) + public ListDirectoryProvider(FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration) { this.ftpClient = ftpClient; this.logger = logger; @@ -23,8 +23,8 @@ public ListDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConf directoryParsers = new List { - new UnixDirectoryParser( logger ), - new DosDirectoryParser( logger ), + new UnixDirectoryParser(logger), + new DosDirectoryParser(logger), }; } @@ -40,8 +40,8 @@ internal void AddParser(IListDirectoryParser parser) private void EnsureLoggedIn() { - if ( !ftpClient.IsConnected || !ftpClient.IsAuthenticated ) - throw new FtpException( "User must be logged in" ); + if (!ftpClient.IsConnected || !ftpClient.IsAuthenticated) + throw new FtpException("User must be logged in"); } public override async Task> ListAllAsync() @@ -62,7 +62,7 @@ public override async Task> ListFilesAsyn try { await ftpClient.dataSocketSemaphore.WaitAsync(); - return await ListNodesAsync( FtpNodeType.File ); + return await ListNodesAsync(FtpNodeType.File); } finally { @@ -75,7 +75,7 @@ public override async Task> ListDirectori try { await ftpClient.dataSocketSemaphore.WaitAsync(); - return await ListNodesAsync( FtpNodeType.Directory ); + return await ListNodesAsync(FtpNodeType.Directory); } finally { @@ -83,41 +83,45 @@ public override async Task> ListDirectori } } - public override IAsyncEnumerable ListAllEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodesEnumerableAsync( null, cancellationToken ); + public override IAsyncEnumerable ListAllEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodesEnumerableAsync(null, cancellationToken); - public override IAsyncEnumerable ListFilesEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodesEnumerableAsync( FtpNodeType.File, cancellationToken ); + public override IAsyncEnumerable ListFilesEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodesEnumerableAsync(FtpNodeType.File, cancellationToken); - public override IAsyncEnumerable ListDirectoriesEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodesEnumerableAsync( FtpNodeType.Directory, cancellationToken ); + public override IAsyncEnumerable ListDirectoriesEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodesEnumerableAsync(FtpNodeType.Directory, cancellationToken); /// /// Lists all nodes (files and directories) in the current working directory /// /// /// - private async Task> ListNodesAsync( FtpNodeType? ftpNodeType = null ) + private async Task> ListNodesAsync(FtpNodeType? ftpNodeType = null) { EnsureLoggedIn(); - logger?.LogDebug( $"[ListDirectoryProvider] Listing {ftpNodeType}" ); + logger?.LogDebug($"[ListDirectoryProvider] Listing {ftpNodeType}"); try { stream = await ftpClient.ConnectDataStreamAsync(); - var result = await ftpClient.ControlStream.SendCommandAsync( new FtpCommandEnvelope + var result = await ftpClient.ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.LIST - } ); + }); - if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) ) - throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage ); + if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) && + (result.FtpStatusCode != FtpStatusCode.OpeningData)) + throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage); - var directoryListing = RetrieveDirectoryListing(); + var directoryListing = await RetrieveDirectoryListingAsync(); - var nodes = ParseLines( directoryListing.ToList().AsReadOnly() ) - .Where( x => !ftpNodeType.HasValue || x.NodeType == ftpNodeType ) + var nodes = ParseLines(directoryListing.AsReadOnly()) + .Where(x => !ftpNodeType.HasValue || x.NodeType == ftpNodeType) .ToList(); return nodes.AsReadOnly(); @@ -131,47 +135,47 @@ private async Task> ListNodesAsync( FtpNo /// /// Streams nodes as they are parsed from the LIST response /// - private async IAsyncEnumerable ListNodesEnumerableAsync( FtpNodeType? ftpNodeType, [EnumeratorCancellation] CancellationToken cancellationToken ) + private async IAsyncEnumerable ListNodesEnumerableAsync(FtpNodeType? ftpNodeType, + [EnumeratorCancellation] CancellationToken cancellationToken) { EnsureLoggedIn(); - logger?.LogDebug( $"[ListDirectoryProvider] Streaming {ftpNodeType}" ); + logger?.LogDebug($"[ListDirectoryProvider] Streaming {ftpNodeType}"); - await ftpClient.dataSocketSemaphore.WaitAsync( cancellationToken ); + await ftpClient.dataSocketSemaphore.WaitAsync(cancellationToken); try { stream = await ftpClient.ConnectDataStreamAsync(); - if ( stream == null ) - throw new FtpException( "Could not establish a data connection" ); + if (stream == null) + throw new FtpException("Could not establish a data connection"); - var result = await ftpClient.ControlStream.SendCommandAsync( new FtpCommandEnvelope + var result = await ftpClient.ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.LIST - } ); + }); - if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) ) - throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage ); + if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) && + (result.FtpStatusCode != FtpStatusCode.OpeningData)) + throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage); IListDirectoryParser parser = null; bool parserResolved = false; - foreach ( string line in RetrieveDirectoryListing() ) + await foreach (string line in RetrieveDirectoryListingEnumerableAsync(cancellationToken)) { - cancellationToken.ThrowIfCancellationRequested(); - - if ( !parserResolved ) + if (!parserResolved) { parser = directoryParsers.Count == 1 - ? directoryParsers[ 0 ] - : directoryParsers.FirstOrDefault( x => x.Test( line ) ); + ? directoryParsers[0] + : directoryParsers.FirstOrDefault(x => x.Test(line)); parserResolved = true; } - if ( parser == null ) + if (parser == null) yield break; - var parsed = parser.Parse( line ); + var parsed = parser.Parse(line); - if ( parsed != null && ( !ftpNodeType.HasValue || parsed.NodeType == ftpNodeType ) ) + if (parsed != null && (!ftpNodeType.HasValue || parsed.NodeType == ftpNodeType)) yield return parsed; } } @@ -183,23 +187,23 @@ private async IAsyncEnumerable ListNodesEnumerableAsync( Ftp } } - private IEnumerable ParseLines( IReadOnlyList lines ) + private IEnumerable ParseLines(IReadOnlyList lines) { - if ( !lines.Any() ) + if (!lines.Any()) yield break; - var parser = directoryParsers.Count == 1 - ? directoryParsers[ 0 ] - : directoryParsers.FirstOrDefault( x => x.Test( lines[ 0 ] ) ); + var parser = directoryParsers.Count == 1 + ? directoryParsers[0] + : directoryParsers.FirstOrDefault(x => x.Test(lines[0])); - if ( parser == null ) + if (parser == null) yield break; - foreach ( string line in lines ) + foreach (string line in lines) { - var parsed = parser.Parse( line ); + var parsed = parser.Parse(line); - if ( parsed != null ) + if (parsed != null) yield return parsed; } } diff --git a/src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs b/src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs index 95c5407..ad080b3 100644 --- a/src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs +++ b/src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs @@ -13,7 +13,7 @@ internal class MlsdDirectoryProvider : DirectoryProviderBase { - public MlsdDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration ) + public MlsdDirectoryProvider(FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration) { this.ftpClient = ftpClient; this.configuration = configuration; @@ -22,8 +22,8 @@ public MlsdDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConf private void EnsureLoggedIn() { - if ( !ftpClient.IsConnected || !ftpClient.IsAuthenticated ) - throw new FtpException( "User must be logged in" ); + if (!ftpClient.IsConnected || !ftpClient.IsAuthenticated) + throw new FtpException("User must be logged in"); } public override async Task> ListAllAsync() @@ -44,7 +44,7 @@ public override async Task> ListFilesAsyn try { await ftpClient.dataSocketSemaphore.WaitAsync(); - return await ListNodeTypeAsync( FtpNodeType.File ); + return await ListNodeTypeAsync(FtpNodeType.File); } finally { @@ -57,7 +57,7 @@ public override async Task> ListDirectori try { await ftpClient.dataSocketSemaphore.WaitAsync(); - return await ListNodeTypeAsync( FtpNodeType.Directory ); + return await ListNodeTypeAsync(FtpNodeType.Directory); } finally { @@ -65,21 +65,24 @@ public override async Task> ListDirectori } } - public override IAsyncEnumerable ListAllEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodeTypeEnumerableAsync( null, cancellationToken ); + public override IAsyncEnumerable ListAllEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodeTypeEnumerableAsync(null, cancellationToken); - public override IAsyncEnumerable ListFilesEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodeTypeEnumerableAsync( FtpNodeType.File, cancellationToken ); + public override IAsyncEnumerable ListFilesEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodeTypeEnumerableAsync(FtpNodeType.File, cancellationToken); - public override IAsyncEnumerable ListDirectoriesEnumerableAsync( CancellationToken cancellationToken = default ) - => ListNodeTypeEnumerableAsync( FtpNodeType.Directory, cancellationToken ); + public override IAsyncEnumerable ListDirectoriesEnumerableAsync( + CancellationToken cancellationToken = default) + => ListNodeTypeEnumerableAsync(FtpNodeType.Directory, cancellationToken); /// /// Lists all nodes (files and directories) in the current working directory /// /// /// - private async Task> ListNodeTypeAsync( FtpNodeType? ftpNodeType = null ) + private async Task> ListNodeTypeAsync(FtpNodeType? ftpNodeType = null) { string nodeTypeString = !ftpNodeType.HasValue ? "all" @@ -87,26 +90,28 @@ private async Task> ListNodeTypeAsync( Ft ? "file" : "dir"; - logger?.LogDebug( $"[MlsdDirectoryProvider] Listing {ftpNodeType}" ); + logger?.LogDebug($"[MlsdDirectoryProvider] Listing {ftpNodeType}"); EnsureLoggedIn(); try { stream = await ftpClient.ConnectDataStreamAsync(); - if ( stream == null ) - throw new FtpException( "Could not establish a data connection" ); + if (stream == null) + throw new FtpException("Could not establish a data connection"); - var result = await ftpClient.ControlStream.SendCommandAsync( FtpCommand.MLSD ); - if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) && ( result.FtpStatusCode != FtpStatusCode.ClosingData ) ) - throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage ); + var result = await ftpClient.ControlStream.SendCommandAsync(FtpCommand.MLSD); + if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) && + (result.FtpStatusCode != FtpStatusCode.OpeningData) && + (result.FtpStatusCode != FtpStatusCode.ClosingData)) + throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage); - var directoryListing = RetrieveDirectoryListing().ToList(); + var directoryListing = await RetrieveDirectoryListingAsync(); - var nodes = ( from node in directoryListing - where !node.IsNullOrWhiteSpace() - where !ftpNodeType.HasValue || node.Contains( $"type={nodeTypeString}" ) - select node.ToFtpNode() ) + var nodes = (from node in directoryListing + where !node.IsNullOrWhiteSpace() + where !ftpNodeType.HasValue || node.Contains($"type={nodeTypeString}") + select node.ToFtpNode()) .ToList(); @@ -122,7 +127,8 @@ select node.ToFtpNode() ) /// /// Streams nodes as they are parsed from the MLSD response /// - private async IAsyncEnumerable ListNodeTypeEnumerableAsync( FtpNodeType? ftpNodeType, [EnumeratorCancellation] CancellationToken cancellationToken ) + private async IAsyncEnumerable ListNodeTypeEnumerableAsync(FtpNodeType? ftpNodeType, + [EnumeratorCancellation] CancellationToken cancellationToken) { string nodeTypeString = !ftpNodeType.HasValue ? "all" @@ -130,29 +136,29 @@ private async IAsyncEnumerable ListNodeTypeEnumerableAsync( ? "file" : "dir"; - logger?.LogDebug( $"[MlsdDirectoryProvider] Streaming {ftpNodeType}" ); + logger?.LogDebug($"[MlsdDirectoryProvider] Streaming {ftpNodeType}"); EnsureLoggedIn(); - await ftpClient.dataSocketSemaphore.WaitAsync( cancellationToken ); + await ftpClient.dataSocketSemaphore.WaitAsync(cancellationToken); try { stream = await ftpClient.ConnectDataStreamAsync(); - if ( stream == null ) - throw new FtpException( "Could not establish a data connection" ); + if (stream == null) + throw new FtpException("Could not establish a data connection"); - var result = await ftpClient.ControlStream.SendCommandAsync( FtpCommand.MLSD ); - if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) && ( result.FtpStatusCode != FtpStatusCode.ClosingData ) ) - throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage ); + var result = await ftpClient.ControlStream.SendCommandAsync(FtpCommand.MLSD); + if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) && + (result.FtpStatusCode != FtpStatusCode.OpeningData) && + (result.FtpStatusCode != FtpStatusCode.ClosingData)) + throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage); - foreach ( string line in RetrieveDirectoryListing() ) + await foreach (string line in RetrieveDirectoryListingEnumerableAsync(cancellationToken)) { - cancellationToken.ThrowIfCancellationRequested(); - - if ( line.IsNullOrWhiteSpace() ) + if (line.IsNullOrWhiteSpace()) continue; - if ( ftpNodeType.HasValue && !line.Contains( $"type={nodeTypeString}" ) ) + if (ftpNodeType.HasValue && !line.Contains($"type={nodeTypeString}")) continue; yield return line.ToFtpNode(); diff --git a/src/CoreFtp/Enum/FtpCommand.cs b/src/CoreFtp/Enum/FtpCommand.cs index b35e317..6486139 100644 --- a/src/CoreFtp/Enum/FtpCommand.cs +++ b/src/CoreFtp/Enum/FtpCommand.cs @@ -25,6 +25,8 @@ public enum FtpCommand TYPE, FEAT, PBSZ, - PROT + PROT, + PORT, + EPRT } } \ No newline at end of file diff --git a/src/CoreFtp/Enum/FtpDataConnectionType.cs b/src/CoreFtp/Enum/FtpDataConnectionType.cs new file mode 100644 index 0000000..a18fcf0 --- /dev/null +++ b/src/CoreFtp/Enum/FtpDataConnectionType.cs @@ -0,0 +1,21 @@ +namespace CoreFtp.Enum +{ + /// + /// Specifies the FTP data connection type used for file transfers and directory listings + /// + public enum FtpDataConnectionType + { + /// + /// Default. Use Extended Passive (EPSV) first, fallback to Passive (PASV). + /// Client connects to the server's data port. + /// + AutoPassive, + + /// + /// Use Active mode (PORT/EPRT). + /// Client listens on a local port and the server connects back to it. + /// Requires the client to be reachable from the server (not behind NAT without configuration). + /// + Active, + } +} diff --git a/src/CoreFtp/FtpClient.cs b/src/CoreFtp/FtpClient.cs index ff0c8b4..5d6dc14 100644 --- a/src/CoreFtp/FtpClient.cs +++ b/src/CoreFtp/FtpClient.cs @@ -22,7 +22,7 @@ public class FtpClient : IFtpClient private IDirectoryProvider directoryProvider; private ILogger logger; private Stream dataStream; - internal readonly SemaphoreSlim dataSocketSemaphore = new SemaphoreSlim( 1, 1 ); + internal readonly SemaphoreSlim dataSocketSemaphore = new SemaphoreSlim(1, 1); public FtpClientConfiguration Configuration { get; private set; } internal IEnumerable Features { get; private set; } @@ -42,30 +42,32 @@ public ILogger Logger } } - public FtpClient() { } + public FtpClient() + { + } - public FtpClient( FtpClientConfiguration configuration ) + public FtpClient(FtpClientConfiguration configuration) { - Configure( configuration ); + Configure(configuration); } - public void Configure( FtpClientConfiguration configuration ) + public void Configure(FtpClientConfiguration configuration) { Configuration = configuration; - if ( configuration.Host == null ) - throw new ArgumentNullException( nameof( configuration.Host ) ); + if (configuration.Host == null) + throw new ArgumentNullException(nameof(configuration.Host)); - if ( Uri.IsWellFormedUriString( configuration.Host, UriKind.Absolute ) ) + if (Uri.IsWellFormedUriString(configuration.Host, UriKind.Absolute)) { - configuration.Host = new Uri( configuration.Host ).Host; + 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( '/' )}"; + Configuration.BaseDirectory = $"/{Configuration.BaseDirectory.TrimStart('/')}"; } /// @@ -74,7 +76,7 @@ public void Configure( FtpClientConfiguration configuration ) /// public async Task LoginAsync() { - if ( IsConnected ) + if (IsConnected) await LogOutAsync(); string username = Configuration.Username.IsNullOrWhiteSpace() @@ -83,49 +85,50 @@ public async Task LoginAsync() await ControlStream.ConnectAsync(); - var usrResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var usrResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.USER, Data = username - } ); + }); - await BailIfResponseNotAsync( usrResponse, FtpStatusCode.SendUserCommand, FtpStatusCode.SendPasswordCommand, FtpStatusCode.LoggedInProceed ); + await BailIfResponseNotAsync(usrResponse, FtpStatusCode.SendUserCommand, FtpStatusCode.SendPasswordCommand, + FtpStatusCode.LoggedInProceed); - var passResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var passResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.PASS, Data = username != Constants.ANONYMOUS_USER ? Configuration.Password : string.Empty - } ); + }); - await BailIfResponseNotAsync( passResponse, FtpStatusCode.LoggedInProceed ); + await BailIfResponseNotAsync(passResponse, FtpStatusCode.LoggedInProceed); IsAuthenticated = true; - if ( ControlStream.IsEncrypted ) + if (ControlStream.IsEncrypted) { - await ControlStream.SendCommandAsync( new FtpCommandEnvelope + await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.PBSZ, Data = "0" - } ); + }); - await ControlStream.SendCommandAsync( new FtpCommandEnvelope + await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.PROT, Data = "P" - } ); + }); } Features = await DetermineFeaturesAsync(); directoryProvider = DetermineDirectoryProvider(); await EnableUTF8IfPossible(); - await SetTransferMode( Configuration.Mode, Configuration.ModeSecondType ); + await SetTransferMode(Configuration.Mode, Configuration.ModeSecondType); - if ( Configuration.BaseDirectory != "/" ) + if (Configuration.BaseDirectory != "/") { - await CreateDirectoryAsync( Configuration.BaseDirectory ); + await CreateDirectoryAsync(Configuration.BaseDirectory); } - await ChangeWorkingDirectoryAsync( Configuration.BaseDirectory ); + await ChangeWorkingDirectoryAsync(Configuration.BaseDirectory); } /// @@ -134,19 +137,19 @@ public async Task LoginAsync() public async Task LogOutAsync() { await IgnoreStaleData(); - if ( !IsConnected ) + if (!IsConnected) return; - Logger?.LogTrace( "[FtpClient] Logging out" ); - try + Logger?.LogTrace("[FtpClient] Logging out"); + try { - await ControlStream.SendCommandAsync( FtpCommand.QUIT ); - } - catch (Exception e) + await ControlStream.SendCommandAsync(FtpCommand.QUIT); + } + catch (Exception e) { - Logger?.LogWarning( 0, e, "Error sending QUIT command during logout" ); + Logger?.LogWarning(0, e, "Error sending QUIT command during logout"); } - finally + finally { ControlStream.Disconnect(); IsAuthenticated = false; @@ -158,29 +161,29 @@ public async Task LogOutAsync() /// /// /// - public async Task ChangeWorkingDirectoryAsync( string directory ) + public async Task ChangeWorkingDirectoryAsync(string directory) { - Logger?.LogTrace( $"[FtpClient] changing directory to {directory}" ); - if ( directory.IsNullOrWhiteSpace() || directory.Equals( "." ) ) - throw new ArgumentOutOfRangeException( nameof( directory ), "Directory supplied was incorrect" ); + Logger?.LogTrace($"[FtpClient] changing directory to {directory}"); + if (directory.IsNullOrWhiteSpace() || directory.Equals(".")) + throw new ArgumentOutOfRangeException(nameof(directory), "Directory supplied was incorrect"); EnsureLoggedIn(); - var response = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var response = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.CWD, Data = directory - } ); + }); - if ( !response.IsSuccess ) - throw new FtpException( response.ResponseMessage ); + if (!response.IsSuccess) + throw new FtpException(response.ResponseMessage); - var pwdResponse = await ControlStream.SendCommandAsync( FtpCommand.PWD ); + var pwdResponse = await ControlStream.SendCommandAsync(FtpCommand.PWD); - if ( !pwdResponse.IsSuccess ) - throw new FtpException( pwdResponse.ResponseMessage ); + if (!pwdResponse.IsSuccess) + throw new FtpException(pwdResponse.ResponseMessage); - WorkingDirectory = ParseWorkingDirectory( pwdResponse.ResponseMessage ); + WorkingDirectory = ParseWorkingDirectory(pwdResponse.ResponseMessage); } private string ParseWorkingDirectory(string responseMessage) @@ -190,13 +193,14 @@ private string ParseWorkingDirectory(string responseMessage) { return parts[1]; } - + // Fallback for unquoted PWD responses like: /home/user is current directory var firstSpace = responseMessage.IndexOf(' '); if (firstSpace != -1) { return responseMessage.Substring(0, firstSpace); } + return responseMessage; } @@ -205,16 +209,16 @@ private string ParseWorkingDirectory(string responseMessage) /// /// /// - public async Task CreateDirectoryAsync( string directory ) + public async Task CreateDirectoryAsync(string directory) { - if ( directory.IsNullOrWhiteSpace() || directory.Equals( "." ) ) - throw new ArgumentOutOfRangeException( nameof( directory ), "Directory supplied was not valid" ); + if (directory.IsNullOrWhiteSpace() || directory.Equals(".")) + throw new ArgumentOutOfRangeException(nameof(directory), "Directory supplied was not valid"); - Logger?.LogDebug( $"[FtpClient] Creating directory {directory}" ); + Logger?.LogDebug($"[FtpClient] Creating directory {directory}"); EnsureLoggedIn(); - await CreateDirectoryStructureRecursively( directory.Split( '/' ), directory.StartsWith( "/" ) ); + await CreateDirectoryStructureRecursively(directory.Split('/'), directory.StartsWith("/")); } /// @@ -223,27 +227,28 @@ public async Task CreateDirectoryAsync( string directory ) /// /// /// - public async Task RenameAsync( string from, string to ) + public async Task RenameAsync(string from, string to) { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Renaming from {from}, to {to}" ); - var renameFromResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + Logger?.LogDebug($"[FtpClient] Renaming from {from}, to {to}"); + var renameFromResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.RNFR, Data = from - } ); + }); - if ( renameFromResponse.FtpStatusCode != FtpStatusCode.FileCommandPending ) - throw new FtpException( renameFromResponse.ResponseMessage ); + if (renameFromResponse.FtpStatusCode != FtpStatusCode.FileCommandPending) + throw new FtpException(renameFromResponse.ResponseMessage); - var renameToResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var renameToResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.RNTO, Data = to - } ); + }); - if ( renameToResponse.FtpStatusCode != FtpStatusCode.FileActionOK && renameToResponse.FtpStatusCode != FtpStatusCode.ClosingData ) - throw new FtpException( renameFromResponse.ResponseMessage ); + if (renameToResponse.FtpStatusCode != FtpStatusCode.FileActionOK && + renameToResponse.FtpStatusCode != FtpStatusCode.ClosingData) + throw new FtpException(renameFromResponse.ResponseMessage); } /// @@ -251,36 +256,36 @@ public async Task RenameAsync( string from, string to ) /// /// /// - public async Task DeleteDirectoryAsync( string directory ) + public async Task DeleteDirectoryAsync(string directory) { - if ( directory.IsNullOrWhiteSpace() || directory.Equals( "." ) ) - throw new ArgumentOutOfRangeException( nameof( directory ), "Directory supplied was not valid" ); + if (directory.IsNullOrWhiteSpace() || directory.Equals(".")) + throw new ArgumentOutOfRangeException(nameof(directory), "Directory supplied was not valid"); - if ( directory == "/" ) + if (directory == "/") return; - Logger?.LogDebug( $"[FtpClient] Deleting directory {directory}" ); + Logger?.LogDebug($"[FtpClient] Deleting directory {directory}"); EnsureLoggedIn(); - var rmdResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var rmdResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.RMD, Data = directory - } ); + }); - switch ( rmdResponse.FtpStatusCode ) + switch (rmdResponse.FtpStatusCode) { case FtpStatusCode.CommandOK: case FtpStatusCode.FileActionOK: return; case FtpStatusCode.ActionNotTakenFileUnavailable: - await DeleteNonEmptyDirectory( directory ); + await DeleteNonEmptyDirectory(directory); return; default: - throw new FtpException( rmdResponse.ResponseMessage ); + throw new FtpException(rmdResponse.ResponseMessage); } } @@ -289,24 +294,24 @@ public async Task DeleteDirectoryAsync( string directory ) /// /// /// - private async Task DeleteNonEmptyDirectory( string directory ) + private async Task DeleteNonEmptyDirectory(string directory) { - await ChangeWorkingDirectoryAsync( directory ); + await ChangeWorkingDirectoryAsync(directory); var allNodes = await ListAllAsync(); - foreach ( var file in allNodes.Where( x => x.NodeType == FtpNodeType.File ) ) + foreach (var file in allNodes.Where(x => x.NodeType == FtpNodeType.File)) { - await DeleteFileAsync( file.Name ); + await DeleteFileAsync(file.Name); } - foreach ( var dir in allNodes.Where( x => x.NodeType == FtpNodeType.Directory ) ) + foreach (var dir in allNodes.Where(x => x.NodeType == FtpNodeType.Directory)) { - await DeleteDirectoryAsync( dir.Name ); + await DeleteDirectoryAsync(dir.Name); } - await ChangeWorkingDirectoryAsync( ".." ); - await DeleteDirectoryAsync( directory ); + await ChangeWorkingDirectoryAsync(".."); + await DeleteDirectoryAsync(directory); } /// @@ -314,16 +319,16 @@ private async Task DeleteNonEmptyDirectory( string directory ) /// /// /// - public async Task SetClientName( string clientName ) + public async Task SetClientName(string clientName) { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Setting client name to {clientName}" ); + Logger?.LogDebug($"[FtpClient] Setting client name to {clientName}"); - return await ControlStream.SendCommandAsync( new FtpCommandEnvelope + return await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.CLNT, Data = clientName - } ); + }); } /// @@ -331,11 +336,11 @@ public async Task SetClientName( string clientName ) /// /// /// - public async Task OpenFileReadStreamAsync( string fileName ) + public async Task OpenFileReadStreamAsync(string fileName) { - Logger?.LogDebug( $"[FtpClient] Opening file read stream for {fileName}" ); + Logger?.LogDebug($"[FtpClient] Opening file read stream for {fileName}"); - return new FtpDataStream( await OpenFileStreamAsync( fileName, FtpCommand.RETR ), this, Logger ); + return new FtpDataStream(await OpenFileStreamAsync(fileName, FtpCommand.RETR), this, Logger); } /// @@ -343,15 +348,16 @@ public async Task OpenFileReadStreamAsync( string fileName ) /// /// /// - public async Task OpenFileWriteStreamAsync( string fileName ) + public async Task OpenFileWriteStreamAsync(string fileName) { - string filePath = WorkingDirectory.CombineAsUriWith( fileName ); - Logger?.LogDebug( $"[FtpClient] Opening file read stream for {filePath}" ); - var segments = filePath.Split( '/' ) - .Where( x => !x.IsNullOrWhiteSpace() ) - .ToList(); - await CreateDirectoryStructureRecursively( segments.Take( segments.Count - 1 ).ToArray(), filePath.StartsWith( "/" ) ); - return new FtpDataStream( await OpenFileStreamAsync( filePath, FtpCommand.STOR ), this, Logger ); + string filePath = WorkingDirectory.CombineAsUriWith(fileName); + Logger?.LogDebug($"[FtpClient] Opening file read stream for {filePath}"); + var segments = filePath.Split('/') + .Where(x => !x.IsNullOrWhiteSpace()) + .ToList(); + await CreateDirectoryStructureRecursively(segments.Take(segments.Count - 1).ToArray(), + filePath.StartsWith("/")); + return new FtpDataStream(await OpenFileStreamAsync(filePath, FtpCommand.STOR), this, Logger); } /// @@ -359,13 +365,13 @@ public async Task OpenFileWriteStreamAsync( string fileName ) /// /// /// - public async Task CloseFileDataStreamAsync( CancellationToken ctsToken = default( CancellationToken ) ) + public async Task CloseFileDataStreamAsync(CancellationToken ctsToken = default(CancellationToken)) { - Logger?.LogTrace( "[FtpClient] Closing write file stream" ); + Logger?.LogTrace("[FtpClient] Closing write file stream"); dataStream.Dispose(); - if ( ControlStream != null ) - await ControlStream.GetResponseAsync( ctsToken ); + if (ControlStream != null) + await ControlStream.GetResponseAsync(ctsToken); } /// @@ -377,7 +383,7 @@ public async Task> ListAllAsync() try { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Listing files in {WorkingDirectory}" ); + Logger?.LogDebug($"[FtpClient] Listing files in {WorkingDirectory}"); return await directoryProvider.ListAllAsync(); } finally @@ -395,7 +401,7 @@ public async Task> ListFilesAsync() try { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Listing files in {WorkingDirectory}" ); + Logger?.LogDebug($"[FtpClient] Listing files in {WorkingDirectory}"); return await directoryProvider.ListFilesAsync(); } finally @@ -413,7 +419,7 @@ public async Task> ListDirectoriesAsync() try { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Listing directories in {WorkingDirectory}" ); + Logger?.LogDebug($"[FtpClient] Listing directories in {WorkingDirectory}"); return await directoryProvider.ListDirectoriesAsync(); } finally @@ -491,18 +497,18 @@ public async Task> ListDirectoriesAsync() /// /// /// - public async Task DeleteFileAsync( string fileName ) + public async Task DeleteFileAsync(string fileName) { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Deleting file {fileName}" ); - var response = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + Logger?.LogDebug($"[FtpClient] Deleting file {fileName}"); + var response = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.DELE, Data = fileName - } ); + }); - if ( !response.IsSuccess ) - throw new FtpException( response.ResponseMessage ); + if (!response.IsSuccess) + throw new FtpException(response.ResponseMessage); } /// @@ -511,20 +517,20 @@ public async Task DeleteFileAsync( string fileName ) /// /// /// - public async Task SetTransferMode( FtpTransferMode transferMode, char secondType = '\0' ) + public async Task SetTransferMode(FtpTransferMode transferMode, char secondType = '\0') { EnsureLoggedIn(); - Logger?.LogTrace( $"[FtpClient] Setting transfer mode {transferMode}, {secondType}" ); - var response = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + Logger?.LogTrace($"[FtpClient] Setting transfer mode {transferMode}, {secondType}"); + var response = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.TYPE, Data = secondType != '\0' - ? $"{(char) transferMode} {secondType}" - : $"{(char) transferMode}" - } ); + ? $"{(char)transferMode} {secondType}" + : $"{(char)transferMode}" + }); - if ( !response.IsSuccess ) - throw new FtpException( response.ResponseMessage ); + if (!response.IsSuccess) + throw new FtpException(response.ResponseMessage); } /// @@ -532,20 +538,20 @@ public async Task SetTransferMode( FtpTransferMode transferMode, char secondType /// /// /// - public async Task GetFileSizeAsync( string fileName ) + public async Task GetFileSizeAsync(string fileName) { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Getting file size for {fileName}" ); - var sizeResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + Logger?.LogDebug($"[FtpClient] Getting file size for {fileName}"); + var sizeResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.SIZE, Data = fileName - } ); + }); - if ( sizeResponse.FtpStatusCode != FtpStatusCode.FileStatus ) - throw new FtpException( sizeResponse.ResponseMessage ); + if (sizeResponse.FtpStatusCode != FtpStatusCode.FileStatus) + throw new FtpException(sizeResponse.ResponseMessage); - long fileSize = long.Parse( sizeResponse.ResponseMessage ); + long fileSize = long.Parse(sizeResponse.ResponseMessage); return fileSize; } @@ -555,46 +561,49 @@ public async Task GetFileSizeAsync( string fileName ) /// private IDirectoryProvider DetermineDirectoryProvider() { - Logger?.LogTrace( "[FtpClient] Determining directory provider" ); + Logger?.LogTrace("[FtpClient] Determining directory provider"); - if ( this.UsesMlsd() ) - return new MlsdDirectoryProvider( this, Logger, Configuration ); + if (this.UsesMlsd()) + return new MlsdDirectoryProvider(this, Logger, Configuration); - if ( Configuration.ForceFileSystem.HasValue ) + if (Configuration.ForceFileSystem.HasValue) { - var forcedProvider = new ListDirectoryProvider( this, Logger, Configuration ); + var forcedProvider = new ListDirectoryProvider(this, Logger, Configuration); forcedProvider.ClearParsers(); - - switch ( Configuration.ForceFileSystem.Value ) + + switch (Configuration.ForceFileSystem.Value) { case FtpFileSystemType.Windows: - forcedProvider.AddParser( new Components.DirectoryListing.Parser.DosDirectoryParser( Logger ) ); + forcedProvider.AddParser(new Components.DirectoryListing.Parser.DosDirectoryParser(Logger)); break; case FtpFileSystemType.Unix: - forcedProvider.AddParser( new Components.DirectoryListing.Parser.UnixDirectoryParser( Logger ) ); + forcedProvider.AddParser(new Components.DirectoryListing.Parser.UnixDirectoryParser(Logger)); break; default: - throw new NotSupportedException( $"Unsupported file system type: {Configuration.ForceFileSystem.Value}" ); + throw new NotSupportedException( + $"Unsupported file system type: {Configuration.ForceFileSystem.Value}"); } - + return forcedProvider; } - return new ListDirectoryProvider( this, Logger, Configuration ); + return new ListDirectoryProvider(this, Logger, Configuration); } private async Task> DetermineFeaturesAsync() { EnsureLoggedIn(); - Logger?.LogTrace( "[FtpClient] Determining features" ); - var response = await ControlStream.SendCommandAsync( FtpCommand.FEAT ); + Logger?.LogTrace("[FtpClient] Determining features"); + var response = await ControlStream.SendCommandAsync(FtpCommand.FEAT); - if ( response.FtpStatusCode == FtpStatusCode.CommandSyntaxError || response.FtpStatusCode == FtpStatusCode.CommandNotImplemented ) + if (response.FtpStatusCode == FtpStatusCode.CommandSyntaxError || + response.FtpStatusCode == FtpStatusCode.CommandNotImplemented) return Enumerable.Empty(); - var features = response.Data.Where( x => !x.StartsWith( ( (int) FtpStatusCode.SystemHelpReply ).ToString() ) && !x.IsNullOrWhiteSpace() ) - .Select( x => x.Replace( Constants.CARRIAGE_RETURN, string.Empty ).Trim() ) - .ToList(); + var features = response.Data.Where(x => + !x.StartsWith(((int)FtpStatusCode.SystemHelpReply).ToString()) && !x.IsNullOrWhiteSpace()) + .Select(x => x.Replace(Constants.CARRIAGE_RETURN, string.Empty).Trim()) + .ToList(); return features; } @@ -605,56 +614,57 @@ private async Task> DetermineFeaturesAsync() /// /// /// - private async Task CreateDirectoryStructureRecursively( IReadOnlyCollection directories, bool isRootedPath ) + private async Task CreateDirectoryStructureRecursively(IReadOnlyCollection directories, + bool isRootedPath) { - Logger?.LogDebug( $"[FtpClient] Creating directory structure recursively {string.Join( "/", directories )}" ); + Logger?.LogDebug($"[FtpClient] Creating directory structure recursively {string.Join("/", directories)}"); string originalPath = WorkingDirectory; - if ( isRootedPath && directories.Any() ) - await ChangeWorkingDirectoryAsync( "/" ); + if (isRootedPath && directories.Any()) + await ChangeWorkingDirectoryAsync("/"); - if ( !directories.Any() ) + if (!directories.Any()) return; - if ( directories.Count == 1 ) + if (directories.Count == 1) { - await ControlStream.SendCommandAsync( new FtpCommandEnvelope + await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.MKD, Data = directories.First() - } ); + }); - await ChangeWorkingDirectoryAsync( originalPath ); + await ChangeWorkingDirectoryAsync(originalPath); return; } - foreach ( string directory in directories ) + foreach (string directory in directories) { - if ( directory.IsNullOrWhiteSpace() ) + if (directory.IsNullOrWhiteSpace()) continue; - var response = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var response = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.CWD, Data = directory - } ); + }); - if ( response.FtpStatusCode != FtpStatusCode.ActionNotTakenFileUnavailable ) + if (response.FtpStatusCode != FtpStatusCode.ActionNotTakenFileUnavailable) continue; - await ControlStream.SendCommandAsync( new FtpCommandEnvelope + await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.MKD, Data = directory - } ); - await ControlStream.SendCommandAsync( new FtpCommandEnvelope + }); + await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = FtpCommand.CWD, Data = directory - } ); + }); } - await ChangeWorkingDirectoryAsync( originalPath ); + await ChangeWorkingDirectoryAsync(originalPath); } @@ -664,22 +674,22 @@ private async Task CreateDirectoryStructureRecursively( IReadOnlyCollection /// /// - private async Task OpenFileStreamAsync( string fileName, FtpCommand command ) + private async Task OpenFileStreamAsync(string fileName, FtpCommand command) { EnsureLoggedIn(); - Logger?.LogDebug( $"[FtpClient] Opening filestream for {fileName}, {command}" ); + Logger?.LogDebug($"[FtpClient] Opening filestream for {fileName}, {command}"); dataStream = await ConnectDataStreamAsync(); - var retrResponse = await ControlStream.SendCommandAsync( new FtpCommandEnvelope + var retrResponse = await ControlStream.SendCommandAsync(new FtpCommandEnvelope { FtpCommand = command, Data = fileName - } ); + }); - if ( ( retrResponse.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && - ( retrResponse.FtpStatusCode != FtpStatusCode.OpeningData ) && - ( retrResponse.FtpStatusCode != FtpStatusCode.ClosingData ) ) - throw new FtpException( retrResponse.ResponseMessage ); + if ((retrResponse.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) && + (retrResponse.FtpStatusCode != FtpStatusCode.OpeningData) && + (retrResponse.FtpStatusCode != FtpStatusCode.ClosingData)) + throw new FtpException(retrResponse.ResponseMessage); return dataStream; } @@ -689,39 +699,105 @@ private async Task OpenFileStreamAsync( string fileName, FtpCommand comm /// private void EnsureLoggedIn() { - if ( !IsConnected || !IsAuthenticated ) - throw new FtpException( "User must be logged in" ); + if (!IsConnected || !IsAuthenticated) + throw new FtpException("User must be logged in"); } /// - /// Produces a data socket using Passive (PASV) or Extended Passive (EPSV) mode + /// Produces a data socket using Passive (PASV/EPSV) or Active (PORT/EPRT) mode /// /// internal async Task ConnectDataStreamAsync() { - Logger?.LogTrace( "[FtpClient] Connecting to a data socket" ); + if (Configuration.DataConnectionType == FtpDataConnectionType.Active) + return await ConnectActiveDataStreamAsync(); - var epsvResult = await ControlStream.SendCommandAsync( FtpCommand.EPSV ); + return await ConnectPassiveDataStreamAsync(); + } + + /// + /// Produces a data socket using Passive (PASV) or Extended Passive (EPSV) mode + /// + private async Task ConnectPassiveDataStreamAsync() + { + Logger?.LogTrace("[FtpClient] Connecting passive data socket"); + + var epsvResult = await ControlStream.SendCommandAsync(FtpCommand.EPSV); int? passivePortNumber; - if ( epsvResult.FtpStatusCode == FtpStatusCode.EnteringExtendedPassive ) + if (epsvResult.FtpStatusCode == FtpStatusCode.EnteringExtendedPassive) { passivePortNumber = epsvResult.ResponseMessage.ExtractEpsvPortNumber(); } else { // EPSV failed - try regular PASV - var pasvResult = await ControlStream.SendCommandAsync( FtpCommand.PASV ); - if ( pasvResult.FtpStatusCode != FtpStatusCode.EnteringPassive ) - throw new FtpException( pasvResult.ResponseMessage ); + var pasvResult = await ControlStream.SendCommandAsync(FtpCommand.PASV); + if (pasvResult.FtpStatusCode != FtpStatusCode.EnteringPassive) + throw new FtpException(pasvResult.ResponseMessage); passivePortNumber = pasvResult.ResponseMessage.ExtractPasvPortNumber(); } - if ( !passivePortNumber.HasValue ) - throw new FtpException( "Could not determine EPSV/PASV data port" ); + if (!passivePortNumber.HasValue) + throw new FtpException("Could not determine EPSV/PASV data port"); + + return await ControlStream.OpenDataStreamAsync(Configuration.Host, passivePortNumber.Value, + CancellationToken.None); + } + + /// + /// Produces a data socket using Active (PORT/EPRT) mode. + /// Binds a local TcpListener, sends PORT, and returns a lazy-accept stream that + /// accepts the server's inbound connection on the first read (after LIST/STOR is sent). + /// + private async Task ConnectActiveDataStreamAsync() + { + Logger?.LogTrace("[FtpClient] Connecting active data socket"); + + var localIp = Configuration.ActiveExternalIp ?? ControlStream.LocalIpAddress; + if (string.IsNullOrEmpty(localIp)) + throw new FtpException( + "Could not determine local IP address for Active mode. Set ActiveExternalIp in configuration."); + + if (!System.Net.IPAddress.TryParse(localIp, out var ipAddress) || + ipAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + throw new FtpException( + $"Active mode requires a valid IPv4 address. Configured address '{localIp}' is invalid or not IPv4."); + + var listener = new System.Net.Sockets.TcpListener(ipAddress, 0); + listener.Start(); + + int localPort = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + + // Send PORT command: PORT h1,h2,h3,h4,p1,p2 + string portCommand = FormatPortCommand(localIp, localPort); + var portResult = await ControlStream.SendCommandAsync(new FtpCommandEnvelope + { + FtpCommand = FtpCommand.PORT, + Data = portCommand + }); + + if (portResult.FtpStatusCode != FtpStatusCode.CommandOK) + { + listener.Stop(); + throw new FtpException("PORT command failed: " + portResult.ResponseMessage); + } + + // Return a lazy-accept stream — the actual accept happens when the caller + // first reads from the stream (i.e. after LIST/STOR has been sent) + return new Infrastructure.Stream.ActiveDataStream(listener, ControlStream, Logger); + } - return await ControlStream.OpenDataStreamAsync( Configuration.Host, passivePortNumber.Value, CancellationToken.None ); + /// + /// Formats an IP and port into PORT command arguments: h1,h2,h3,h4,p1,p2 + /// + private static string FormatPortCommand(string ip, int port) + { + string ipPart = ip.Replace('.', ','); + int highByte = port / 256; + int lowByte = port % 256; + return $"{ipPart},{highByte},{lowByte}"; } /// @@ -730,15 +806,16 @@ internal async Task ConnectDataStreamAsync() /// /// /// - private async Task BailIfResponseNotAsync( FtpResponse response, params FtpStatusCode[] codes ) + private async Task BailIfResponseNotAsync(FtpResponse response, params FtpStatusCode[] codes) { - if ( codes.Any( x => x == response.FtpStatusCode ) ) + if (codes.Any(x => x == response.FtpStatusCode)) return; - Logger?.LogDebug( $"Bailing due to response codes being {response.FtpStatusCode}, which is not one of: [{string.Join( ",", codes )}]" ); + Logger?.LogDebug( + $"Bailing due to response codes being {response.FtpStatusCode}, which is not one of: [{string.Join(",", codes)}]"); await LogOutAsync(); - throw new FtpException( response.ResponseMessage ); + throw new FtpException(response.ResponseMessage); } /// @@ -747,28 +824,30 @@ private async Task BailIfResponseNotAsync( FtpResponse response, params FtpStatu /// private async Task EnableUTF8IfPossible() { - if ( Equals( ControlStream.Encoding, Encoding.ASCII ) && Features.Any( x => x == Constants.UTF8 ) ) + if (Equals(ControlStream.Encoding, Encoding.ASCII) && Features.Any(x => x == Constants.UTF8)) { ControlStream.Encoding = Encoding.UTF8; } - if ( Equals( ControlStream.Encoding, Encoding.UTF8 ) ) + if (Equals(ControlStream.Encoding, Encoding.UTF8)) { // If the server supports UTF8 it should already be enabled and this // command should not matter however there are conflicting drafts // about this so we'll just execute it to be safe. - await ControlStream.SendCommandAsync( "OPTS UTF8 ON" ); + await ControlStream.SendCommandAsync("OPTS UTF8 ON"); } } - public async Task SendCommandAsync( FtpCommandEnvelope envelope, CancellationToken token = default( CancellationToken ) ) + public async Task SendCommandAsync(FtpCommandEnvelope envelope, + CancellationToken token = default(CancellationToken)) { - return await ControlStream.SendCommandAsync( envelope, token ); + return await ControlStream.SendCommandAsync(envelope, token); } - public async Task SendCommandAsync( string command, CancellationToken token = default( CancellationToken ) ) + public async Task SendCommandAsync(string command, + CancellationToken token = default(CancellationToken)) { - return await ControlStream.SendCommandAsync( command, token ); + return await ControlStream.SendCommandAsync(command, token); } /// @@ -777,28 +856,28 @@ private async Task EnableUTF8IfPossible() /// private async Task IgnoreStaleData() { - if ( IsConnected && ControlStream.SocketDataAvailable() ) + if (IsConnected && ControlStream.SocketDataAvailable()) { var staleData = await ControlStream.GetResponseAsync(); - Logger?.LogWarning( $"Stale data detected: {staleData.ResponseMessage}" ); + Logger?.LogWarning($"Stale data detected: {staleData.ResponseMessage}"); } } public void Dispose() { - Logger?.LogDebug( "Disposing of FtpClient" ); - try + Logger?.LogDebug("Disposing of FtpClient"); + try { if (IsConnected) { LogOutAsync().GetAwaiter().GetResult(); } - } + } catch (Exception ex) { Logger?.LogWarning(0, ex, "Exception during logout on dispose."); } - + dataStream?.Dispose(); ControlStream?.Dispose(); dataSocketSemaphore?.Dispose(); diff --git a/src/CoreFtp/FtpClientConfiguration.cs b/src/CoreFtp/FtpClientConfiguration.cs index aaeb111..2aa6d78 100644 --- a/src/CoreFtp/FtpClientConfiguration.cs +++ b/src/CoreFtp/FtpClientConfiguration.cs @@ -47,5 +47,19 @@ public class FtpClientConfiguration /// and the automatic detection of the LIST output format fails (e.g. force FtpFileSystemType.Windows). /// public FtpFileSystemType? ForceFileSystem { get; set; } + + /// + /// Specifies the FTP data connection type. Default is + /// which uses EPSV/PASV (client connects to server's data port). + /// Set to to use PORT/EPRT (server connects back to client). + /// + public FtpDataConnectionType DataConnectionType { get; set; } = FtpDataConnectionType.AutoPassive; + + /// + /// The external IP address to advertise in Active mode PORT/EPRT commands. + /// Required when the client is behind NAT and the server cannot reach the client's local IP. + /// If null, the local IP address of the control connection socket is used. + /// + public string ActiveExternalIp { get; set; } } } diff --git a/src/CoreFtp/Infrastructure/Stream/ActiveDataStream.cs b/src/CoreFtp/Infrastructure/Stream/ActiveDataStream.cs new file mode 100644 index 0000000..e0e19f5 --- /dev/null +++ b/src/CoreFtp/Infrastructure/Stream/ActiveDataStream.cs @@ -0,0 +1,161 @@ +namespace CoreFtp.Infrastructure.Stream +{ + using System; + using System.IO; + using System.Net.Sockets; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + /// + /// A wrapper stream for Active FTP mode that lazily accepts the server's + /// inbound data connection on the first read operation. This defers the + /// TcpListener.AcceptTcpClient call until after the data command (e.g. LIST) + /// has been sent on the control channel. + /// + internal class ActiveDataStream : Stream + { + private readonly TcpListener listener; + private readonly FtpControlStream controlStream; + private readonly ILogger logger; + private Stream innerStream; + private TcpClient acceptedClient; + private bool accepted; + private bool disposed; + + public ActiveDataStream(TcpListener listener, FtpControlStream controlStream, ILogger logger) + { + this.listener = listener ?? throw new ArgumentNullException(nameof(listener)); + this.controlStream = controlStream ?? throw new ArgumentNullException(nameof(controlStream)); + this.logger = logger; + } + + private async Task EnsureAcceptedAsync(CancellationToken cancellationToken = default) + { + if (accepted) + return; + + logger?.LogDebug("[ActiveDataStream] Accepting inbound data connection from server"); + + var timeoutTask = Task.Delay(controlStream.Configuration.TimeoutSeconds * 1000, cancellationToken); + var acceptTask = listener.AcceptTcpClientAsync(); + + var completedTask = await Task.WhenAny(acceptTask, timeoutTask); + if (completedTask == timeoutTask) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException("Timeout waiting for Active mode data connection."); + } + + acceptedClient = await acceptTask; + + // Validate source IP to prevent data connection hijacking + var remoteEndpoint = acceptedClient.Client.RemoteEndPoint as System.Net.IPEndPoint; + var controlEndpoint = controlStream.RemoteEndPoint; + + if (remoteEndpoint != null && controlEndpoint != null && + !remoteEndpoint.Address.Equals(controlEndpoint.Address)) + { + acceptedClient.Dispose(); + throw new FtpException( + $"Rejected active data connection from unexpected source IP: {remoteEndpoint.Address}"); + } + + innerStream = await controlStream.WrapDataStreamAsync(acceptedClient); + accepted = true; + logger?.LogDebug("[ActiveDataStream] Data connection accepted"); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => innerStream?.Length ?? 0; + + public override long Position + { + get => innerStream?.Position ?? 0; + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException( + "Synchronous operations are not supported on this stream. Use ReadAsync instead."); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + await EnsureAcceptedAsync(cancellationToken); + return await innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException( + "Synchronous operations are not supported on this stream. Use WriteAsync instead."); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await EnsureAcceptedAsync(cancellationToken); + await innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + +#if !NETSTANDARD2_0 && !NET462 + public override int Read(Span buffer) + { + throw new NotSupportedException( + "Synchronous operations are not supported on this stream. Use ReadAsync instead."); + } + + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + await EnsureAcceptedAsync(cancellationToken); + return await innerStream.ReadAsync(buffer, cancellationToken); + } + + public override void Write(ReadOnlySpan buffer) + { + throw new NotSupportedException( + "Synchronous operations are not supported on this stream. Use WriteAsync instead."); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + await EnsureAcceptedAsync(cancellationToken); + await innerStream.WriteAsync(buffer, cancellationToken); + } +#endif + + public override void Flush() + { + innerStream?.Flush(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (!disposed && disposing) + { + innerStream?.Dispose(); + acceptedClient?.Dispose(); + listener?.Stop(); + disposed = true; + } + + base.Dispose(disposing); + } + } +} diff --git a/src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs b/src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs index 79a5a0b..a6fbddd 100644 --- a/src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs +++ b/src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs @@ -18,7 +18,7 @@ public class FtpControlStream : Stream { - protected readonly FtpClientConfiguration Configuration; + internal readonly FtpClientConfiguration Configuration; public ILogger Logger; protected readonly IDnsResolver dnsResolver; protected Socket Socket; @@ -36,6 +36,20 @@ public class FtpControlStream : Stream internal bool IsDataConnection { get; set; } + /// + /// Returns the local IP address of the control connection socket. + /// Used by Active mode to determine the IP to advertise in PORT commands. + /// + internal string LocalIpAddress + { + get + { + if (Socket?.LocalEndPoint is System.Net.IPEndPoint localEndPoint) + return localEndPoint.Address.ToString(); + return null; + } + } + internal void SetTimeouts(int milliseconds) { BaseStream.ReadTimeout = milliseconds; @@ -48,6 +62,8 @@ internal void ResetTimeouts() BaseStream.WriteTimeout = Configuration.TimeoutSeconds * 1000; } + internal System.Net.IPEndPoint RemoteEndPoint => Socket?.RemoteEndPoint as System.Net.IPEndPoint; + public FtpControlStream(FtpClientConfiguration configuration, IDnsResolver dnsResolver) { Logger?.LogDebug("Constructing new FtpSocketStream"); @@ -141,8 +157,6 @@ private int Read(Span buffer) } - - public override void Write(byte[] buffer, int offset, int count) { NetworkStream?.Write(buffer, offset, count); @@ -222,6 +236,7 @@ protected string ReadLine(Encoding encoding, CancellationToken token) throw new FtpException("Line length limit exceeded"); continue; } + line = encoding.GetString(data.ToArray()).Trim('\r', '\n'); break; } @@ -345,6 +360,29 @@ public async Task OpenDataStreamAsync(string host, int port, Cancellatio return socketStream; } + /// + /// Wraps an accepted TcpClient (from Active mode) into a data connection stream. + /// Applies TLS if the control connection is encrypted. + /// + internal async Task WrapDataStreamAsync(System.Net.Sockets.TcpClient acceptedClient) + { + Logger?.LogDebug("[FtpSocketStream] Wrapping accepted data connection"); + var socketStream = new FtpControlStream(Configuration, dnsResolver) + { Logger = Logger, IsDataConnection = true }; + + socketStream.Socket = acceptedClient.Client; + socketStream.Socket.ReceiveTimeout = Configuration.TimeoutSeconds * 1000; + socketStream.Socket.LingerState = new LingerOption(true, 0); + socketStream.BaseStream = new NetworkStream(acceptedClient.Client); + + if (IsEncrypted) + { + await socketStream.ActivateEncryptionAsync(); + } + + return socketStream; + } + protected async Task ConnectStreamAsync(CancellationToken token) { await ConnectStreamAsync(Configuration.Host, Configuration.Port, token); diff --git a/tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs b/tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs new file mode 100644 index 0000000..3c7659f --- /dev/null +++ b/tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs @@ -0,0 +1,219 @@ +namespace CoreFtp.Tests.Integration.FtpClientTests +{ + using System; + using System.IO; + using System.Linq; + using System.Net; + using System.Net.Sockets; + using System.Text; + using System.Threading.Tasks; + using Enum; + using FluentAssertions; + using Microsoft.Extensions.Logging; + using Shared; + using Xunit; + using Xunit.Abstractions; + + public class ActiveModeTests : IDisposable + { + private readonly ILogger logger; + private readonly TcpListener controlListener; + private readonly int controlPort; + + public ActiveModeTests(ITestOutputHelper outputHelper) + { + var factory = LoggerFactory.Create(builder => + builder.AddProvider(new XunitLoggerProvider(outputHelper))); + logger = factory.CreateLogger(); + + controlListener = new TcpListener(IPAddress.Loopback, 0); + controlListener.Start(); + controlPort = ((IPEndPoint)controlListener.LocalEndpoint).Port; + } + + public void Dispose() + { + controlListener.Stop(); + } + + private async Task RunFakeFtpServer(Func handler) + { + using (var client = await controlListener.AcceptTcpClientAsync()) + using (var stream = client.GetStream()) + using (var reader = new StreamReader(stream, Encoding.ASCII)) + using (var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true }) + { + await handler(reader, writer); + } + } + + private static (string ip, int port) ParsePortCommand(string portArgs) + { + var parts = portArgs.Split(','); + string ip = $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}"; + int port = int.Parse(parts[4]) * 256 + int.Parse(parts[5]); + return (ip, port); + } + + [Fact] + public async Task ListAllAsync_ActiveMode_ReturnsDirectoryListing() + { + var serverTask = RunFakeFtpServer(async (reader, writer) => + { + // Greeting + await writer.WriteLineAsync("220 Welcome"); + + // USER + await reader.ReadLineAsync(); + await writer.WriteLineAsync("331 Password required"); + + // PASS + await reader.ReadLineAsync(); + await writer.WriteLineAsync("230 Logged in"); + + // FEAT + await reader.ReadLineAsync(); + await writer.WriteLineAsync("211-Features:"); + await writer.WriteLineAsync(" UTF8"); + await writer.WriteLineAsync("211 End"); + + // OPTS UTF8 ON + await reader.ReadLineAsync(); + await writer.WriteLineAsync("200 OK"); + + // TYPE I + await reader.ReadLineAsync(); + await writer.WriteLineAsync("200 OK"); + + // CWD / + await reader.ReadLineAsync(); + await writer.WriteLineAsync("250 CWD OK"); + // PWD + await reader.ReadLineAsync(); + await writer.WriteLineAsync("257 \"/\""); + + // PORT command + var portLine = await reader.ReadLineAsync(); + var portArgs = portLine.Substring("PORT ".Length); + var (ip, port) = ParsePortCommand(portArgs); + await writer.WriteLineAsync("200 PORT command successful"); + + // LIST + await reader.ReadLineAsync(); + await writer.WriteLineAsync("150 Opening data connection"); + + // Server connects back to client's data listener + using (var dataClient = new TcpClient()) + { + await dataClient.ConnectAsync(ip, port); + using (var dataStream = dataClient.GetStream()) + using (var dataWriter = new StreamWriter(dataStream, Encoding.ASCII) { AutoFlush = true }) + { + await dataWriter.WriteLineAsync("-rw-r--r-- 1 user group 1024 Jan 01 00:00 file1.txt"); + await dataWriter.WriteLineAsync("drwxr-xr-x 2 user group 4096 Jan 01 00:00 subdir"); + await dataWriter.WriteLineAsync("-rw-r--r-- 1 user group 2048 Jan 01 00:00 file2.log"); + } + } + + await writer.WriteLineAsync("226 Transfer complete"); + }); + + var config = new FtpClientConfiguration + { + Host = "localhost", + Port = controlPort, + Username = "test", + Password = "pwd", + DataConnectionType = FtpDataConnectionType.Active, + ActiveExternalIp = "127.0.0.1" + }; + + using (var client = new FtpClient(config) { Logger = logger }) + { + await client.LoginAsync(); + + var nodes = await client.ListAllAsync(); + + nodes.Should().HaveCount(3); + nodes.Count(n => n.NodeType == FtpNodeType.File).Should().Be(2); + nodes.Count(n => n.NodeType == FtpNodeType.Directory).Should().Be(1); + nodes.Any(n => n.Name == "file1.txt").Should().BeTrue(); + nodes.Any(n => n.Name == "subdir").Should().BeTrue(); + nodes.Any(n => n.Name == "file2.log").Should().BeTrue(); + } + + await serverTask; + } + + [Fact] + public async Task ListFilesAsync_ActiveMode_ReturnsOnlyFiles() + { + var serverTask = RunFakeFtpServer(async (reader, writer) => + { + await writer.WriteLineAsync("220 Welcome"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("331 Password required"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("230 Logged in"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("211-Features:"); + await writer.WriteLineAsync(" UTF8"); + await writer.WriteLineAsync("211 End"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("200 OK"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("200 OK"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("250 CWD OK"); + await reader.ReadLineAsync(); + await writer.WriteLineAsync("257 \"/\""); + + // PORT command + var portLine = await reader.ReadLineAsync(); + var portArgs = portLine.Substring("PORT ".Length); + var (ip, port) = ParsePortCommand(portArgs); + await writer.WriteLineAsync("200 PORT command successful"); + + // LIST + await reader.ReadLineAsync(); + await writer.WriteLineAsync("150 Opening data connection"); + + using (var dataClient = new TcpClient()) + { + await dataClient.ConnectAsync(ip, port); + using (var dataStream = dataClient.GetStream()) + using (var dataWriter = new StreamWriter(dataStream, Encoding.ASCII) { AutoFlush = true }) + { + await dataWriter.WriteLineAsync("-rw-r--r-- 1 user group 1024 Jan 01 00:00 readme.md"); + await dataWriter.WriteLineAsync("drwxr-xr-x 2 user group 4096 Jan 01 00:00 docs"); + } + } + + await writer.WriteLineAsync("226 Transfer complete"); + }); + + var config = new FtpClientConfiguration + { + Host = "localhost", + Port = controlPort, + Username = "test", + Password = "pwd", + DataConnectionType = FtpDataConnectionType.Active, + ActiveExternalIp = "127.0.0.1" + }; + + using (var client = new FtpClient(config) { Logger = logger }) + { + await client.LoginAsync(); + + var files = await client.ListFilesAsync(); + + files.Should().HaveCount(1); + files[0].Name.Should().Be("readme.md"); + files[0].NodeType.Should().Be(FtpNodeType.File); + } + + await serverTask; + } + } +}