forked from testcontainers/Docker.DotNet
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHttpConnection.cs
More file actions
222 lines (193 loc) · 8.28 KB
/
HttpConnection.cs
File metadata and controls
222 lines (193 loc) · 8.28 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
namespace Microsoft.Net.Http.Client;
internal sealed class HttpConnection : IDisposable
{
private static readonly ISet<string> DockerStreamHeaders = new HashSet<string> { "application/vnd.docker.raw-stream", "application/vnd.docker.multiplexed-stream" };
public HttpConnection(BufferedReadStream transport)
{
Transport = transport;
}
public BufferedReadStream Transport { get; }
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
// Serialize headers & send
string rawRequest = SerializeRequest(request);
byte[] requestBytes = Encoding.ASCII.GetBytes(rawRequest);
await Transport.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken)
.ConfigureAwait(false);
if (request.Content != null)
{
if (request.Content.Headers.ContentLength.HasValue)
{
#if NET6_0_OR_GREATER
await request.Content.CopyToAsync(Transport, cancellationToken)
.ConfigureAwait(false);
#else
await request.Content.CopyToAsync(Transport)
.ConfigureAwait(false);
#endif
}
else
{
// The length of the data is unknown. Send it in chunked mode.
using (var chunkedStream = new ChunkedWriteStream(Transport))
{
#if NET6_0_OR_GREATER
await request.Content.CopyToAsync(chunkedStream, cancellationToken)
.ConfigureAwait(false);
#else
await request.Content.CopyToAsync(chunkedStream)
.ConfigureAwait(false);
#endif
await chunkedStream.EndContentAsync(cancellationToken)
.ConfigureAwait(false);
}
}
}
// Receive headers
List<string> responseLines = await ReadResponseLinesAsync(cancellationToken)
.ConfigureAwait(false);
// Receive body and determine the response type (Content-Length, Transfer-Encoding, Opaque)
return CreateResponseMessage(responseLines);
}
catch (Exception ex)
{
Dispose(); // Any errors at this layer abort the connection.
throw new HttpRequestException("The requested failed, see inner exception for details.", ex);
}
}
private string SerializeRequest(HttpRequestMessage request)
{
StringBuilder builder = new StringBuilder();
builder.Append(request.Method);
builder.Append(' ');
builder.Append(request.GetAddressLineProperty());
builder.Append(" HTTP/");
builder.Append(request.Version.ToString(2));
builder.Append("\r\n");
AppendHeaders(builder, request.Headers);
if (request.Content != null)
{
// Force the content to compute its content length if it has not already.
var contentLength = request.Content.Headers.ContentLength;
if (contentLength.HasValue)
{
request.Content.Headers.ContentLength = contentLength.Value;
}
AppendHeaders(builder, request.Content.Headers);
if (!contentLength.HasValue)
{
// Add header for chunked mode.
builder.Append("Transfer-Encoding: chunked\r\n");
}
}
// Headers end with an empty line
builder.Append("\r\n");
return builder.ToString();
}
// HttpHeaders.ToString() uses Environment.NewLine which is \n on macOS/Linux.
// RFC 9112 §2.2 requires \r\n regardless of platform, so we serialize headers explicitly.
private static void AppendHeaders(StringBuilder builder, HttpHeaders headers)
{
foreach (var header in headers)
{
builder.Append(header.Key);
builder.Append(": ");
builder.Append(string.Join(", ", header.Value));
builder.Append("\r\n");
}
}
private async Task<List<string>> ReadResponseLinesAsync(CancellationToken cancellationToken)
{
var lines = new List<string>(12);
do
{
var line = await Transport.ReadLineAsync(cancellationToken)
.ConfigureAwait(false);
if (string.IsNullOrEmpty(line))
{
break;
}
lines.Add(line);
}
while (true);
return lines;
}
private HttpResponseMessage CreateResponseMessage(List<string> responseLines)
{
string responseLine = responseLines.First();
// HTTP/1.1 200 OK
string[] responseLineParts = responseLine.Split(new[] { ' ' }, 3);
// TODO: Verify HTTP/1.0 or 1.1.
if (responseLineParts.Length < 2)
{
throw new HttpRequestException("Invalid response line: " + responseLine);
}
if (int.TryParse(responseLineParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var statusCode))
{
// TODO: Validate range
}
else
{
throw new HttpRequestException("Invalid status code: " + responseLineParts[1]);
}
HttpResponseMessage response = new HttpResponseMessage((HttpStatusCode)statusCode);
if (responseLineParts.Length >= 3)
{
response.ReasonPhrase = responseLineParts[2];
}
var content = new HttpConnectionResponseContent(this);
response.Content = content;
foreach (var rawHeader in responseLines.Skip(1))
{
int colonOffset = rawHeader.IndexOf(':');
if (colonOffset <= 0)
{
throw new HttpRequestException("The given header line format is invalid: " + rawHeader);
}
string headerName = rawHeader.Substring(0, colonOffset);
string headerValue = rawHeader.Substring(colonOffset + 2);
if (!response.Headers.TryAddWithoutValidation(headerName, headerValue))
{
bool success = response.Content.Headers.TryAddWithoutValidation(headerName, headerValue);
System.Diagnostics.Debug.Assert(success, "Failed to add response header: " + rawHeader);
}
}
// Response handling is based on headers and type. The implementation currently covers
// four main cases:
//
// 1. Chunked transfer encoding (HTTP/1.1 `Transfer-Encoding: chunked`)
// 2. HTTP responses with a `Content-Length` header
// - For 101 Switching Protocols, `Content-Length` is ignored per RFC 9110
// 3. Protocol upgrades (HTTP 101 + `Upgrade: tcp`)
// - e.g., `/containers/{id}/attach` or `/exec/{id}/start`
// 4. Streaming responses without connection upgrade headers
// - e.g., `/containers/{id}/logs`
//
// This separation ensures chunked framing, streaming, and upgraded connections are handled
// correctly, while tolerating proxies that incorrectly send `Content-Length: 0` on upgrades.
var isSwitchingProtocols = response.StatusCode == HttpStatusCode.SwitchingProtocols;
var isConnectionUpgrade = response.Headers.TryGetValues("Upgrade", out var responseHeaderValues)
&& responseHeaderValues.Any(header => "tcp".Equals(header, StringComparison.OrdinalIgnoreCase));
var isStream = content.Headers.TryGetValues("Content-Type", out var contentHeaderValues)
&& contentHeaderValues.Any(DockerStreamHeaders.Contains);
// Treat the response as chunked for standard HTTP chunked or Docker raw-streams,
// but not for upgraded connections.
var isChunkedTransferEncoding = (response.Headers.TransferEncodingChunked.GetValueOrDefault() && !isConnectionUpgrade)
|| (!isConnectionUpgrade && isStream);
if (isSwitchingProtocols && isConnectionUpgrade)
{
content.ResolveResponseStream(false, true);
}
else
{
content.ResolveResponseStream(isChunkedTransferEncoding, isConnectionUpgrade);
}
return response;
}
public void Dispose()
{
Transport.Dispose();
}
}