This repository was archived by the owner on Aug 5, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFtpControlChannel.cs
More file actions
243 lines (205 loc) · 9.24 KB
/
Copy pathFtpControlChannel.cs
File metadata and controls
243 lines (205 loc) · 9.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
using System.Globalization;
using System.Text;
namespace FtpClient.Protocol;
/// <summary>
/// The FTP control connection: sends commands, reads replies, and hides the fact that a
/// reply may arrive as several lines and several TCP reads.
/// </summary>
/// <remarks>
/// <para>
/// A reply is either one line — <c>200 Command okay</c> — or a block whose first line has a
/// hyphen where the space would be, ending at the first line that repeats the same code
/// followed by a space (RFC 959 §4.2). The block form is not exotic: it is what every
/// server uses for its greeting and for <c>FEAT</c>.
/// </para>
/// <para>
/// TCP does not preserve message boundaries, so none of this can assume that one read
/// yields one line, or that a line yields a whole reply. The reader below keeps a buffer
/// across reads and only surfaces a reply once it is complete.
/// </para>
/// </remarks>
public sealed class FtpControlChannel : IDisposable
{
/// <summary>
/// FTP is defined on 7-bit ASCII, but every server in practice passes 8-bit bytes
/// through untouched, and modern ones use UTF-8. Decoding as UTF-8 is therefore both
/// correct for UTF-8 servers and harmless for ASCII ones.
/// </summary>
private static readonly UTF8Encoding Encoding = new(encoderShouldEmitUTF8Identifier: false);
/// <summary>
/// A server that never sends a line terminator would otherwise grow our buffer without
/// bound. No real reply comes close to this.
/// </summary>
private const int MaxLineLength = 64 * 1024;
private readonly Stream _stream;
private readonly bool _ownsStream;
private readonly byte[] _buffer;
private int _bufferStart;
private int _bufferEnd;
private bool _disposed;
public FtpControlChannel(Stream stream, bool ownsStream = true, int bufferSize = 4096)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentOutOfRangeException.ThrowIfLessThan(bufferSize, 64);
_stream = stream;
_ownsStream = ownsStream;
_buffer = new byte[bufferSize];
}
/// <summary>Commands and replies as they crossed the wire, for logging and tests.</summary>
public event EventHandler<FtpTraceEventArgs>? Traced;
/// <summary>Sends a command and waits for the reply it produces.</summary>
/// <param name="command">The verb, e.g. <c>PASV</c>.</param>
/// <param name="argument">The argument, if the command takes one.</param>
/// <param name="cancellationToken">Cancels the send and the wait for a reply.</param>
public async Task<FtpReply> SendAsync(
string command, string? argument = null, CancellationToken cancellationToken = default)
{
await WriteAsync(command, argument, cancellationToken).ConfigureAwait(false);
return await ReadReplyAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a command and throws unless the reply is a success. Used for the many commands
/// whose only interesting outcome is "did it work".
/// </summary>
public async Task<FtpReply> SendExpectingSuccessAsync(
string command, string? argument = null, CancellationToken cancellationToken = default)
{
FtpReply reply = await SendAsync(command, argument, cancellationToken).ConfigureAwait(false);
return reply.IsSuccess
? reply
: throw new FtpProtocolException($"{command} failed.", reply);
}
/// <summary>Sends a command without waiting for its reply.</summary>
public async Task WriteAsync(
string command, string? argument = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
string line = argument is null ? command : $"{command} {argument}";
OnTraced(FtpTraceDirection.Sent, Redact(command, line));
byte[] bytes = Encoding.GetBytes(line + "\r\n");
await _stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await _stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Reads one complete reply, following continuation lines until the block closes.
/// </summary>
public async Task<FtpReply> ReadReplyAsync(CancellationToken cancellationToken = default)
{
string first = await ReadLineAsync(cancellationToken).ConfigureAwait(false)
?? throw new FtpProtocolException("The server closed the control connection.");
OnTraced(FtpTraceDirection.Received, first);
if (!TryReadCode(first, out int code, out char separator))
{
throw new FtpProtocolException($"Malformed reply: '{first}'.");
}
if (separator != '-')
{
return new FtpReply(code, first.Length > 4 ? first[4..] : string.Empty);
}
// Multi-line: keep going until a line repeats the opening code followed by a space.
// Intermediate lines may themselves start with digits, so the code alone is not
// enough to decide — the separator has to be checked too.
StringBuilder text = new(first.Length > 4 ? first[4..] : string.Empty);
while (true)
{
string line = await ReadLineAsync(cancellationToken).ConfigureAwait(false)
?? throw new FtpProtocolException(
$"The server closed the control connection inside a multi-line {code} reply.");
OnTraced(FtpTraceDirection.Received, line);
if (TryReadCode(line, out int lineCode, out char lineSeparator)
&& lineCode == code
&& lineSeparator == ' ')
{
text.Append('\n').Append(line.Length > 4 ? line[4..] : string.Empty);
return new FtpReply(code, text.ToString());
}
text.Append('\n').Append(line);
}
}
/// <summary>
/// Reads one CRLF-terminated line, refilling the buffer as needed. Returns <c>null</c>
/// at a clean end of stream.
/// </summary>
/// <remarks>
/// The line is assembled first and stripped of its carriage return afterwards. Trimming
/// each buffer's tail instead would eat a CR that happened to land at the end of a read
/// with its LF in the next one.
/// </remarks>
private async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
{
StringBuilder? overflow = null;
while (true)
{
int newline = Array.IndexOf(_buffer, (byte)'\n', _bufferStart, _bufferEnd - _bufferStart);
if (newline >= 0)
{
string tail = Decode(_bufferStart, newline - _bufferStart);
_bufferStart = newline + 1;
return (overflow is null ? tail : overflow.Append(tail).ToString()).TrimEnd('\r');
}
// No terminator in what we hold. Keep the partial text and read more; this is
// the path taken whenever a reply is split across TCP segments.
overflow ??= new StringBuilder();
overflow.Append(Decode(_bufferStart, _bufferEnd - _bufferStart));
_bufferStart = 0;
_bufferEnd = 0;
if (overflow.Length > MaxLineLength)
{
throw new FtpProtocolException(
$"A control-connection line exceeded {MaxLineLength} characters without terminating.");
}
int read = await _stream.ReadAsync(_buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
return overflow.Length == 0 ? null : overflow.ToString().TrimEnd('\r');
}
_bufferEnd = read;
}
}
private string Decode(int start, int length) =>
length <= 0 ? string.Empty : Encoding.GetString(_buffer, start, length);
private static bool TryReadCode(string line, out int code, out char separator)
{
code = 0;
separator = '\0';
if (line.Length < 4
|| !char.IsAsciiDigit(line[0]) || !char.IsAsciiDigit(line[1]) || !char.IsAsciiDigit(line[2])
|| (line[3] != ' ' && line[3] != '-'))
{
return false;
}
code = int.Parse(line.AsSpan(0, 3), CultureInfo.InvariantCulture);
separator = line[3];
return true;
}
/// <summary>Keeps the password out of the trace, which is the one thing a log must not carry.</summary>
private static string Redact(string command, string line) =>
command.Equals("PASS", StringComparison.OrdinalIgnoreCase) ? "PASS ****" : line;
private void OnTraced(FtpTraceDirection direction, string line) =>
Traced?.Invoke(this, new FtpTraceEventArgs(direction, line));
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_ownsStream)
{
_stream.Dispose();
}
}
}
/// <summary>Which way a traced line was travelling.</summary>
public enum FtpTraceDirection
{
Sent,
Received,
}
/// <summary>One line of control-channel traffic.</summary>
public sealed class FtpTraceEventArgs(FtpTraceDirection direction, string line) : EventArgs
{
public FtpTraceDirection Direction { get; } = direction;
public string Line { get; } = line;
public override string ToString() => (Direction == FtpTraceDirection.Sent ? "> " : "< ") + Line;
}