Skip to content

Commit e286ef5

Browse files
sparkeh9Nick Briscoe
andauthored
Add Active FTP mode support (Issue #43) (#55)
* Add Active FTP mode support (Issue #43) - Add FtpDataConnectionType enum (AutoPassive, Active) - Add PORT and EPRT to FtpCommand enum - Add DataConnectionType and ActiveExternalIp config properties - Refactor ConnectDataStreamAsync into Passive/Active branches - Active mode: binds TcpListener, sends PORT, returns lazy-accept ActiveDataStream - ActiveDataStream defers AcceptTcpClient until first Read to avoid deadlock - Add WrapDataStreamAsync and LocalIpAddress to FtpControlStream - Add 2 integration tests using in-process fake FTP server - All 23 tests pass, no breaking changes * Address PR feedback for Active FTP mode - Validate active data peer IP matches control connection IP - Fix sync-over-async in ActiveDataStream by properly overriding Memory/Span APIs and disallowing sync I/O - Add cancellation/timeout support to ActiveDataStream accept logic - Bind active mode listener explicitly to resolved IPv4 address instead of IPAddress.Any - WrapDataStreamAsync applies LingerState and ReceiveTimeout correctly - Refactored RetrieveDirectoryListing to be async instead of blocking byte-by-byte reads - Ensure WrapDataStreamAsync API is internal - Update test field naming conventions * chore: remove accidental test logs and temp files * style: revert underscore fields in ActiveModeTests.cs per repo conventions --------- Co-authored-by: Nick Briscoe <nick.briscoe@razor.co.uk>
1 parent 5561958 commit e286ef5

10 files changed

Lines changed: 858 additions & 311 deletions

File tree

src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,35 @@ internal abstract class DirectoryProviderBase : IDirectoryProvider
1818
protected ILogger logger;
1919
protected Stream stream;
2020

21-
protected IEnumerable<string> RetrieveDirectoryListing()
21+
protected async Task<List<string>> RetrieveDirectoryListingAsync()
2222
{
23-
string line;
24-
while ( ( line = ReadLine( ftpClient.ControlStream.Encoding ) ) != null )
23+
var lines = new List<string>();
24+
using (var reader = new StreamReader(stream, ftpClient.ControlStream.Encoding))
2525
{
26-
logger?.LogDebug( line );
27-
yield return line;
26+
string line;
27+
while ((line = await reader.ReadLineAsync()) != null)
28+
{
29+
logger?.LogDebug(line);
30+
lines.Add(line);
31+
}
2832
}
33+
34+
return lines;
2935
}
3036

31-
protected string ReadLine( Encoding encoding )
37+
protected async IAsyncEnumerable<string> RetrieveDirectoryListingEnumerableAsync(
38+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
3239
{
33-
if ( encoding == null )
34-
throw new ArgumentNullException( nameof( encoding ) );
35-
36-
var data = new List<byte>();
37-
var buf = new byte[1];
38-
string line = null;
39-
40-
while ( stream.Read( buf, 0, buf.Length ) > 0 )
40+
using (var reader = new StreamReader(stream, ftpClient.ControlStream.Encoding))
4141
{
42-
data.Add( buf[ 0 ] );
43-
if ( (char) buf[ 0 ] != '\n' )
44-
continue;
45-
line = encoding.GetString( data.ToArray() ).Trim( '\r', '\n' );
46-
break;
42+
string line;
43+
while ((line = await reader.ReadLineAsync()) != null)
44+
{
45+
cancellationToken.ThrowIfCancellationRequested();
46+
logger?.LogDebug(line);
47+
yield return line;
48+
}
4749
}
48-
49-
return line;
5050
}
5151

5252
public virtual Task<ReadOnlyCollection<FtpNodeInformation>> ListAllAsync()
@@ -64,17 +64,20 @@ public virtual Task<ReadOnlyCollection<FtpNodeInformation>> ListDirectoriesAsync
6464
throw new NotImplementedException();
6565
}
6666

67-
public virtual IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( CancellationToken cancellationToken = default )
67+
public virtual IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync(
68+
CancellationToken cancellationToken = default)
6869
{
6970
throw new NotImplementedException();
7071
}
7172

72-
public virtual IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync( CancellationToken cancellationToken = default )
73+
public virtual IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync(
74+
CancellationToken cancellationToken = default)
7375
{
7476
throw new NotImplementedException();
7577
}
7678

77-
public virtual IAsyncEnumerable<FtpNodeInformation> ListDirectoriesEnumerableAsync( CancellationToken cancellationToken = default )
79+
public virtual IAsyncEnumerable<FtpNodeInformation> ListDirectoriesEnumerableAsync(
80+
CancellationToken cancellationToken = default)
7881
{
7982
throw new NotImplementedException();
8083
}

src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ internal class ListDirectoryProvider : DirectoryProviderBase
1515
{
1616
private readonly List<IListDirectoryParser> directoryParsers;
1717

18-
public ListDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration )
18+
public ListDirectoryProvider(FtpClient ftpClient, ILogger logger, FtpClientConfiguration configuration)
1919
{
2020
this.ftpClient = ftpClient;
2121
this.logger = logger;
2222
this.configuration = configuration;
2323

2424
directoryParsers = new List<IListDirectoryParser>
2525
{
26-
new UnixDirectoryParser( logger ),
27-
new DosDirectoryParser( logger ),
26+
new UnixDirectoryParser(logger),
27+
new DosDirectoryParser(logger),
2828
};
2929
}
3030

@@ -40,8 +40,8 @@ internal void AddParser(IListDirectoryParser parser)
4040

4141
private void EnsureLoggedIn()
4242
{
43-
if ( !ftpClient.IsConnected || !ftpClient.IsAuthenticated )
44-
throw new FtpException( "User must be logged in" );
43+
if (!ftpClient.IsConnected || !ftpClient.IsAuthenticated)
44+
throw new FtpException("User must be logged in");
4545
}
4646

4747
public override async Task<ReadOnlyCollection<FtpNodeInformation>> ListAllAsync()
@@ -62,7 +62,7 @@ public override async Task<ReadOnlyCollection<FtpNodeInformation>> ListFilesAsyn
6262
try
6363
{
6464
await ftpClient.dataSocketSemaphore.WaitAsync();
65-
return await ListNodesAsync( FtpNodeType.File );
65+
return await ListNodesAsync(FtpNodeType.File);
6666
}
6767
finally
6868
{
@@ -75,49 +75,53 @@ public override async Task<ReadOnlyCollection<FtpNodeInformation>> ListDirectori
7575
try
7676
{
7777
await ftpClient.dataSocketSemaphore.WaitAsync();
78-
return await ListNodesAsync( FtpNodeType.Directory );
78+
return await ListNodesAsync(FtpNodeType.Directory);
7979
}
8080
finally
8181
{
8282
ftpClient.dataSocketSemaphore.Release();
8383
}
8484
}
8585

86-
public override IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( CancellationToken cancellationToken = default )
87-
=> ListNodesEnumerableAsync( null, cancellationToken );
86+
public override IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync(
87+
CancellationToken cancellationToken = default)
88+
=> ListNodesEnumerableAsync(null, cancellationToken);
8889

89-
public override IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync( CancellationToken cancellationToken = default )
90-
=> ListNodesEnumerableAsync( FtpNodeType.File, cancellationToken );
90+
public override IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync(
91+
CancellationToken cancellationToken = default)
92+
=> ListNodesEnumerableAsync(FtpNodeType.File, cancellationToken);
9193

92-
public override IAsyncEnumerable<FtpNodeInformation> ListDirectoriesEnumerableAsync( CancellationToken cancellationToken = default )
93-
=> ListNodesEnumerableAsync( FtpNodeType.Directory, cancellationToken );
94+
public override IAsyncEnumerable<FtpNodeInformation> ListDirectoriesEnumerableAsync(
95+
CancellationToken cancellationToken = default)
96+
=> ListNodesEnumerableAsync(FtpNodeType.Directory, cancellationToken);
9497

9598
/// <summary>
9699
/// Lists all nodes (files and directories) in the current working directory
97100
/// </summary>
98101
/// <param name="ftpNodeType"></param>
99102
/// <returns></returns>
100-
private async Task<ReadOnlyCollection<FtpNodeInformation>> ListNodesAsync( FtpNodeType? ftpNodeType = null )
103+
private async Task<ReadOnlyCollection<FtpNodeInformation>> ListNodesAsync(FtpNodeType? ftpNodeType = null)
101104
{
102105
EnsureLoggedIn();
103-
logger?.LogDebug( $"[ListDirectoryProvider] Listing {ftpNodeType}" );
106+
logger?.LogDebug($"[ListDirectoryProvider] Listing {ftpNodeType}");
104107

105108
try
106109
{
107110
stream = await ftpClient.ConnectDataStreamAsync();
108111

109-
var result = await ftpClient.ControlStream.SendCommandAsync( new FtpCommandEnvelope
112+
var result = await ftpClient.ControlStream.SendCommandAsync(new FtpCommandEnvelope
110113
{
111114
FtpCommand = FtpCommand.LIST
112-
} );
115+
});
113116

114-
if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) )
115-
throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage );
117+
if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) &&
118+
(result.FtpStatusCode != FtpStatusCode.OpeningData))
119+
throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage);
116120

117-
var directoryListing = RetrieveDirectoryListing();
121+
var directoryListing = await RetrieveDirectoryListingAsync();
118122

119-
var nodes = ParseLines( directoryListing.ToList().AsReadOnly() )
120-
.Where( x => !ftpNodeType.HasValue || x.NodeType == ftpNodeType )
123+
var nodes = ParseLines(directoryListing.AsReadOnly())
124+
.Where(x => !ftpNodeType.HasValue || x.NodeType == ftpNodeType)
121125
.ToList();
122126

123127
return nodes.AsReadOnly();
@@ -131,47 +135,47 @@ private async Task<ReadOnlyCollection<FtpNodeInformation>> ListNodesAsync( FtpNo
131135
/// <summary>
132136
/// Streams nodes as they are parsed from the LIST response
133137
/// </summary>
134-
private async IAsyncEnumerable<FtpNodeInformation> ListNodesEnumerableAsync( FtpNodeType? ftpNodeType, [EnumeratorCancellation] CancellationToken cancellationToken )
138+
private async IAsyncEnumerable<FtpNodeInformation> ListNodesEnumerableAsync(FtpNodeType? ftpNodeType,
139+
[EnumeratorCancellation] CancellationToken cancellationToken)
135140
{
136141
EnsureLoggedIn();
137-
logger?.LogDebug( $"[ListDirectoryProvider] Streaming {ftpNodeType}" );
142+
logger?.LogDebug($"[ListDirectoryProvider] Streaming {ftpNodeType}");
138143

139-
await ftpClient.dataSocketSemaphore.WaitAsync( cancellationToken );
144+
await ftpClient.dataSocketSemaphore.WaitAsync(cancellationToken);
140145
try
141146
{
142147
stream = await ftpClient.ConnectDataStreamAsync();
143-
if ( stream == null )
144-
throw new FtpException( "Could not establish a data connection" );
148+
if (stream == null)
149+
throw new FtpException("Could not establish a data connection");
145150

146-
var result = await ftpClient.ControlStream.SendCommandAsync( new FtpCommandEnvelope
151+
var result = await ftpClient.ControlStream.SendCommandAsync(new FtpCommandEnvelope
147152
{
148153
FtpCommand = FtpCommand.LIST
149-
} );
154+
});
150155

151-
if ( ( result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen ) && ( result.FtpStatusCode != FtpStatusCode.OpeningData ) )
152-
throw new FtpException( "Could not retrieve directory listing " + result.ResponseMessage );
156+
if ((result.FtpStatusCode != FtpStatusCode.DataAlreadyOpen) &&
157+
(result.FtpStatusCode != FtpStatusCode.OpeningData))
158+
throw new FtpException("Could not retrieve directory listing " + result.ResponseMessage);
153159

154160
IListDirectoryParser parser = null;
155161
bool parserResolved = false;
156162

157-
foreach ( string line in RetrieveDirectoryListing() )
163+
await foreach (string line in RetrieveDirectoryListingEnumerableAsync(cancellationToken))
158164
{
159-
cancellationToken.ThrowIfCancellationRequested();
160-
161-
if ( !parserResolved )
165+
if (!parserResolved)
162166
{
163167
parser = directoryParsers.Count == 1
164-
? directoryParsers[ 0 ]
165-
: directoryParsers.FirstOrDefault( x => x.Test( line ) );
168+
? directoryParsers[0]
169+
: directoryParsers.FirstOrDefault(x => x.Test(line));
166170
parserResolved = true;
167171
}
168172

169-
if ( parser == null )
173+
if (parser == null)
170174
yield break;
171175

172-
var parsed = parser.Parse( line );
176+
var parsed = parser.Parse(line);
173177

174-
if ( parsed != null && ( !ftpNodeType.HasValue || parsed.NodeType == ftpNodeType ) )
178+
if (parsed != null && (!ftpNodeType.HasValue || parsed.NodeType == ftpNodeType))
175179
yield return parsed;
176180
}
177181
}
@@ -183,23 +187,23 @@ private async IAsyncEnumerable<FtpNodeInformation> ListNodesEnumerableAsync( Ftp
183187
}
184188
}
185189

186-
private IEnumerable<FtpNodeInformation> ParseLines( IReadOnlyList<string> lines )
190+
private IEnumerable<FtpNodeInformation> ParseLines(IReadOnlyList<string> lines)
187191
{
188-
if ( !lines.Any() )
192+
if (!lines.Any())
189193
yield break;
190194

191-
var parser = directoryParsers.Count == 1
192-
? directoryParsers[ 0 ]
193-
: directoryParsers.FirstOrDefault( x => x.Test( lines[ 0 ] ) );
195+
var parser = directoryParsers.Count == 1
196+
? directoryParsers[0]
197+
: directoryParsers.FirstOrDefault(x => x.Test(lines[0]));
194198

195-
if ( parser == null )
199+
if (parser == null)
196200
yield break;
197201

198-
foreach ( string line in lines )
202+
foreach (string line in lines)
199203
{
200-
var parsed = parser.Parse( line );
204+
var parsed = parser.Parse(line);
201205

202-
if ( parsed != null )
206+
if (parsed != null)
203207
yield return parsed;
204208
}
205209
}

0 commit comments

Comments
 (0)