From 15df7ab8c310cbd35e2964a54d1e46a4d99d32c9 Mon Sep 17 00:00:00 2001 From: Cody Mullins <1738479+codymullins@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:10:38 -0400 Subject: [PATCH] net: replace hand-rolled transport with HttpClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the ~7,300-line hand-rolled Starling.Net stack (UDP DNS, raw Socket dialer, BouncyCastle TLS, HTTP/1.1 parser, HTTP/2 + HPACK, connection pool, body decoders) for System.Net.Http.HttpClient over a configured SocketsHttpHandler. This reverses the founding "no HttpClient, no SslStream" decision, approved by the user, and accepts the native TLS dependency. StarlingHttpClient stays the public facade, so consumers (fetch, XHR, iframe, engine fetchers) do not change. Browser policy stays above the transport: - AllowAutoRedirect=false — the engine follows redirects. - UseCookies=false — our CookieJar (PSL, prefixes) drives cookies. - SecureConnector does the TLS handshake in ConnectCallback, validates the chain against the bundled CCADB root store via a managed CertificateVerifier (X509Chain + CustomRootTrust, no OS store), captures the verified leaf per origin for the lock UI, and holds the dns/tcp/tls telemetry spans. - Response headers are copied via the NonValidated view so duplicate Set-Cookie survives for fetch getSetCookie()/getAllResponseHeaders(). Net.Net drops from ~7,315 to ~1,793 production lines. Drops the BouncyCastle.Cryptography dependency. Retires the internal-protocol tests (H1/H2/HPACK, DNS, TCP, pool) and adds managed cert-verify and header-fidelity tests. Docs updated: 00_INDEX locked decision, 03_NETWORKING banner, AGENTS interop carve-out. --- AGENTS.md | 16 +- bench/Starling.Bench/H1ResponseBench.cs | 81 -- browser-plan/00_INDEX.md | 2 +- browser-plan/03_NETWORKING.md | 32 +- src/Starling.Net/Dns/DnsCache.cs | 86 -- src/Starling.Net/Dns/DnsMessage.cs | 284 ----- src/Starling.Net/Dns/DnsResolver.cs | 160 --- src/Starling.Net/Dns/IDnsTransport.cs | 11 - src/Starling.Net/Dns/UdpDnsTransport.cs | 44 - src/Starling.Net/Http/ConnectionPool.cs | 286 ------ src/Starling.Net/Http/Decoding/BodyDecoder.cs | 103 -- .../Http/Decoding/ChunkedReader.cs | 105 -- .../Http/Decoding/InboundBuffer.cs | 233 ----- src/Starling.Net/Http/H1/H1RequestWriter.cs | 166 --- src/Starling.Net/Http/H1/H1ResponseParser.cs | 345 ------- src/Starling.Net/Http/H2/AsyncSignal.cs | 42 - src/Starling.Net/Http/H2/H2Connection.cs | 972 ------------------ .../Http/H2/H2ConnectionManager.cs | 102 -- src/Starling.Net/Http/H2/H2Exception.cs | 15 - src/Starling.Net/Http/H2/H2FrameReader.cs | 66 -- src/Starling.Net/Http/H2/H2FrameWriter.cs | 156 --- src/Starling.Net/Http/H2/H2Protocol.cs | 84 -- src/Starling.Net/Http/H2/H2Stream.cs | 34 - .../Http/H2/Hpack/HpackDecoder.cs | 174 ---- .../Http/H2/Hpack/HpackDynamicTable.cs | 92 -- .../Http/H2/Hpack/HpackEncoder.cs | 73 -- .../Http/H2/Hpack/HpackHuffman.cs | 215 ---- .../Http/H2/Hpack/HpackInteger.cs | 97 -- .../Http/H2/Hpack/HpackStaticTable.cs | 125 --- src/Starling.Net/Http/HttpError.cs | 23 - src/Starling.Net/Http/IHttpTransport.cs | 39 - src/Starling.Net/Http/OriginKey.cs | 37 - src/Starling.Net/Http/PooledHttpTransport.cs | 93 -- src/Starling.Net/Http/SecureConnector.cs | 128 +++ src/Starling.Net/Starling.Net.csproj | 1 - src/Starling.Net/StarlingHttpClient.cs | 555 +++------- src/Starling.Net/Tcp/ITcpConnection.cs | 23 - src/Starling.Net/Tcp/SocketTcpConnection.cs | 87 -- src/Starling.Net/Tcp/TcpDialer.cs | 91 -- src/Starling.Net/Tcp/TcpEndpoint.cs | 26 - src/Starling.Net/Tls/BcDuplexTlsStream.cs | 238 ----- src/Starling.Net/Tls/BcTlsTransport.cs | 100 -- src/Starling.Net/Tls/CertificateVerifier.cs | 237 +---- src/Starling.Net/Tls/ITlsTransport.cs | 13 - src/Starling.Net/Tls/RevocationSet.cs | 188 ---- src/Starling.Net/Tls/RootCertificates.cs | 42 +- .../Tls/StarlingTlsAuthentication.cs | 38 - src/Starling.Net/Tls/StarlingTlsClient.cs | 75 -- .../Tls/SystemRootCertificates.cs | 21 +- src/Starling.Net/Tls/TcpConnectionStream.cs | 62 -- src/Starling.Net/Tls/TlsClientOptions.cs | 10 - src/Starling.Net/Tls/TlsError.cs | 8 - .../Starling.Engine.Tests/EngineHttpTests.cs | 5 - .../Starling.Net.Tests/Dns/DnsMessageTests.cs | 120 --- .../Dns/DnsResolverTests.cs | 213 ---- .../Http/ChunkedReaderTests.cs | 213 ---- .../Http/ConnectionPoolIntegrationTests.cs | 353 ------- .../Http/ConnectionPoolTests.cs | 212 ---- .../Http/H1RequestWriterTests.cs | 183 ---- .../Http/H1ResponseParserTests.cs | 296 ------ .../Http/H2/H2ConnectionTests.cs | 213 ---- .../Http/H2/H2FrameTests.cs | 97 -- .../Starling.Net.Tests/Http/H2/HpackTests.cs | 159 --- .../Http/StarlingHttpClientTests.cs | 29 +- .../Starling.Net.Tests/Tcp/TcpDialerTests.cs | 244 ----- .../Tls/CertificateVerifierTests.cs | 70 ++ .../Starling.Net.Tests/Tls/TlsClientTests.cs | 396 ------- 67 files changed, 473 insertions(+), 8666 deletions(-) delete mode 100644 bench/Starling.Bench/H1ResponseBench.cs delete mode 100644 src/Starling.Net/Dns/DnsCache.cs delete mode 100644 src/Starling.Net/Dns/DnsMessage.cs delete mode 100644 src/Starling.Net/Dns/DnsResolver.cs delete mode 100644 src/Starling.Net/Dns/IDnsTransport.cs delete mode 100644 src/Starling.Net/Dns/UdpDnsTransport.cs delete mode 100644 src/Starling.Net/Http/ConnectionPool.cs delete mode 100644 src/Starling.Net/Http/Decoding/BodyDecoder.cs delete mode 100644 src/Starling.Net/Http/Decoding/ChunkedReader.cs delete mode 100644 src/Starling.Net/Http/Decoding/InboundBuffer.cs delete mode 100644 src/Starling.Net/Http/H1/H1RequestWriter.cs delete mode 100644 src/Starling.Net/Http/H1/H1ResponseParser.cs delete mode 100644 src/Starling.Net/Http/H2/AsyncSignal.cs delete mode 100644 src/Starling.Net/Http/H2/H2Connection.cs delete mode 100644 src/Starling.Net/Http/H2/H2ConnectionManager.cs delete mode 100644 src/Starling.Net/Http/H2/H2Exception.cs delete mode 100644 src/Starling.Net/Http/H2/H2FrameReader.cs delete mode 100644 src/Starling.Net/Http/H2/H2FrameWriter.cs delete mode 100644 src/Starling.Net/Http/H2/H2Protocol.cs delete mode 100644 src/Starling.Net/Http/H2/H2Stream.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackInteger.cs delete mode 100644 src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs delete mode 100644 src/Starling.Net/Http/HttpError.cs delete mode 100644 src/Starling.Net/Http/IHttpTransport.cs delete mode 100644 src/Starling.Net/Http/OriginKey.cs delete mode 100644 src/Starling.Net/Http/PooledHttpTransport.cs create mode 100644 src/Starling.Net/Http/SecureConnector.cs delete mode 100644 src/Starling.Net/Tcp/ITcpConnection.cs delete mode 100644 src/Starling.Net/Tcp/SocketTcpConnection.cs delete mode 100644 src/Starling.Net/Tcp/TcpDialer.cs delete mode 100644 src/Starling.Net/Tcp/TcpEndpoint.cs delete mode 100644 src/Starling.Net/Tls/BcDuplexTlsStream.cs delete mode 100644 src/Starling.Net/Tls/BcTlsTransport.cs delete mode 100644 src/Starling.Net/Tls/ITlsTransport.cs delete mode 100644 src/Starling.Net/Tls/RevocationSet.cs delete mode 100644 src/Starling.Net/Tls/StarlingTlsAuthentication.cs delete mode 100644 src/Starling.Net/Tls/StarlingTlsClient.cs delete mode 100644 src/Starling.Net/Tls/TcpConnectionStream.cs delete mode 100644 src/Starling.Net/Tls/TlsClientOptions.cs delete mode 100644 src/Starling.Net/Tls/TlsError.cs delete mode 100644 tests/Starling.Net.Tests/Dns/DnsMessageTests.cs delete mode 100644 tests/Starling.Net.Tests/Dns/DnsResolverTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/H2/H2FrameTests.cs delete mode 100644 tests/Starling.Net.Tests/Http/H2/HpackTests.cs delete mode 100644 tests/Starling.Net.Tests/Tcp/TcpDialerTests.cs create mode 100644 tests/Starling.Net.Tests/Tls/CertificateVerifierTests.cs delete mode 100644 tests/Starling.Net.Tests/Tls/TlsClientTests.cs diff --git a/AGENTS.md b/AGENTS.md index 736d8a7e..92f4c0c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -277,14 +277,14 @@ libpng/libjpeg/libwebp on Linux. It is also the only engine project with `AllowUnsafeBlocks=true`. Every other engine module under `src/Starling.{Common,Url,Net,Html,Dom,Css,Layout,Paint,Js,Bindings,Mcp,Telemetry,Engine}/` stays **pure managed** — no P/Invoke, no native dependencies beyond what the -.NET BCL ships. **TLS path: BouncyCastle.** `Starling.Net` uses -`BouncyCastle.Cryptography` (pure-managed, no P/Invoke) for TLS 1.3 via -`BcTlsTransport`. The `wp:M3-06e` SslStream migration was rolled back in -`939f3a5 fix ssl crash` (2026-05-14) after a macOS TLS 1.3 issue surfaced in -integration; re-attempting SslStream — or formally re-blessing BouncyCastle as -the long-term path — is a tracked open item in `wp:M3-06-native-interop-pivot`'s -handoff log. The interop-seam policy is still satisfied either way, because -BouncyCastle adds no native dependency. CI greps the engine-project allowlist +.NET BCL ships. **One carve-out: `Starling.Net`.** As of 2026-07-07 it runs on +`System.Net.Http.HttpClient` over `SocketsHttpHandler` (transport, TLS, HTTP/1.1, +HTTP/2), which reaches native crypto through the BCL. The BouncyCastle TLS client +was deleted. `Starling.Net` still writes no P/Invoke of its own, so it stays off +the interop grep, but it is no longer strictly pure-managed at runtime — the +"managed-first" rule now means "no P/Invoke in our code," not "no native code +anywhere below us." Cert trust stays ours: `HttpClient` chains to the bundled +CCADB root store via a custom `ConnectCallback`, not the OS store. CI greps the engine-project allowlist (every engine project *except* the Codecs interop project); the lint job fails if you regress it. The GUI shell (`src/Starling.Gui`, Avalonia 12) and the Aspire AppHost/ServiceDefaults projects are exempt — they link against Avalonia desktop diff --git a/bench/Starling.Bench/H1ResponseBench.cs b/bench/Starling.Bench/H1ResponseBench.cs deleted file mode 100644 index d3119173..00000000 --- a/bench/Starling.Bench/H1ResponseBench.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text; -using BenchmarkDotNet.Attributes; -using Starling.Net.Http.H1; - -namespace Starling.Bench; - -// HTTP/1.1 response parsing — every fetch path hits this. The keep-alive -// connection pool (wp:M2-07c) calls `H1ResponseParser` per response, so any -// regression here multiplies across the subresource graph. Body is read off -// an in-memory `MemoryStream`, so the bench is socket-free. -[MemoryDiagnoser] -public class H1ResponseBench -{ - private byte[] _smallBody = null!; // Content-Length, ~1 KB - private byte[] _largeBody = null!; // Content-Length, ~64 KB - private byte[] _chunkedBody = null!; // chunked, ~16 KB across 8 chunks - - [GlobalSetup] - public void Setup() - { - _smallBody = BuildContentLengthResponse(payloadBytes: 1024); - _largeBody = BuildContentLengthResponse(payloadBytes: 64 * 1024); - _chunkedBody = BuildChunkedResponse(chunks: 8, perChunk: 2048); - } - - [Benchmark] - public int ContentLength_1KB() - { - var stream = new MemoryStream(_smallBody); - var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult(); - return result.Value.Body.Length; - } - - [Benchmark] - public int ContentLength_64KB() - { - var stream = new MemoryStream(_largeBody); - var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult(); - return result.Value.Body.Length; - } - - [Benchmark] - public int Chunked_16KB_8Chunks() - { - var stream = new MemoryStream(_chunkedBody); - var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult(); - return result.Value.Body.Length; - } - - private static byte[] BuildContentLengthResponse(int payloadBytes) - { - var head = $"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {payloadBytes}\r\nConnection: keep-alive\r\n\r\n"; - var bytes = new byte[Encoding.ASCII.GetByteCount(head) + payloadBytes]; - var off = Encoding.ASCII.GetBytes(head, bytes); - for (var i = 0; i < payloadBytes; i++) - { - bytes[off + i] = (byte)('a' + (i % 26)); - } - - return bytes; - } - - private static byte[] BuildChunkedResponse(int chunks, int perChunk) - { - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n"); - for (var c = 0; c < chunks; c++) - { - sb.Append(perChunk.ToString("X", System.Globalization.CultureInfo.InvariantCulture)); - sb.Append("\r\n"); - for (var i = 0; i < perChunk; i++) - { - sb.Append((char)('a' + (i % 26))); - } - - sb.Append("\r\n"); - } - sb.Append("0\r\n\r\n"); - return Encoding.ASCII.GetBytes(sb.ToString()); - } -} diff --git a/browser-plan/00_INDEX.md b/browser-plan/00_INDEX.md index d7d58477..14fc2f41 100644 --- a/browser-plan/00_INDEX.md +++ b/browser-plan/00_INDEX.md @@ -27,7 +27,7 @@ | UI | Avalonia 12 (stable 12.0.x, released Apr 2026; targets .NET 10 directly; .NET 8+ only) | user | | Rasterization | `SixLabors.ImageSharp` 3.x + `SixLabors.ImageSharp.Drawing` 2.x + `SixLabors.Fonts` 2.x | user | | JS engine | The Starling JS engine, written from scratch in C#. No third-party JS engine dependencies. | user | -| Networking | Hand-written from `System.Net.Sockets` up. No `HttpClient`, no `SslStream`. | user | +| Networking | `System.Net.Http.HttpClient` over a configured `SocketsHttpHandler`, wrapped by the `StarlingHttpClient` facade. Browser policy (redirects, cookies, cert trust) stays in Starling; the transport (HTTP/1.1, HTTP/2, TLS) is the BCL's. Reversed the earlier hand-rolled-from-`Sockets` decision on 2026-07-07; see `03_NETWORKING.md`. | user | | Process model | Single-process for v1. Ladybird-style multi-process sandboxing deferred to v2. | this plan | | Cross-platform | Windows + macOS + Linux from day one. No platform branches without an `OPEN QUESTION`. | user | | Threading | Single-threaded UI + event loop. Worker pools for parsing/networking/JS. Details in `01_ARCHITECTURE.md`. | this plan | diff --git a/browser-plan/03_NETWORKING.md b/browser-plan/03_NETWORKING.md index b923bb72..1e2ab1c3 100644 --- a/browser-plan/03_NETWORKING.md +++ b/browser-plan/03_NETWORKING.md @@ -1,5 +1,21 @@ # 03 — Networking +> **Status (2026-07-07): switched to `HttpClient`.** The hand-rolled transport — +> UDP DNS, the raw `Socket` dialer, the BouncyCastle TLS client, the HTTP/1.1 +> parser, the HTTP/2 + HPACK stack, the connection pool, and the body decoders — +> was deleted. `StarlingHttpClient` is now a thin browser-policy wrapper over +> `System.Net.Http.HttpClient` on a configured `SocketsHttpHandler`. This +> reverses the "no `HttpClient`, no `SslStream`" rule that the rest of this doc +> was written under. What Starling still owns: redirects (the engine follows +> them; `AllowAutoRedirect` is off), cookies (our `CookieJar`, not +> `CookieContainer`; `UseCookies` is off), and cert trust (the bundled CCADB +> root store via a `ConnectCallback` that also captures the leaf for the lock +> UI). What the BCL now owns: TCP, TLS, HTTP/1.1, HTTP/2, decompression, and +> pooling — plus native crypto under the hood, which is why the engine is no +> longer strictly pure-managed (see `AGENTS.md` interop policy). The sections +> below describe the retired design and are kept for history; trust this banner +> where they disagree. + ## Scope **In:** URL parsing, DNS, TCP, TLS 1.3 (via `SslStream`), HTTP/1.1, HTTP/2 + HPACK, cookies, content decoding (gzip/brotli/deflate), HTTP cache, fetch primitives. Public seam for the engine. @@ -42,11 +58,13 @@ its clean bill on the CI grep. > `wp:M3-06-native-interop-pivot`'s handoff log. See `AGENTS.md` §"Interop > policy" for the current authoritative statement. -**No `HttpClient`.** We do not use `System.Net.Http.HttpClient` — the HTTP/1.1 -stack (and the planned HTTP/2 stack) is hand-rolled. That is the whole point of -this doc: the engine owns connection pooling, cookies, caching, redirects, and -cert trust, none of which `HttpClient` lets us control to browser spec. The ban -on `HttpClient` is unchanged. +**`HttpClient` (as of 2026-07-07).** Superseded by the status banner at the top. +We now use `System.Net.Http.HttpClient` over `SocketsHttpHandler`. The pieces a +browser must control — redirects, cookies, and cert trust — are kept above the +transport by turning off the handler's automatic redirect and cookie handling +and by validating certificates against the bundled root store in a custom +`ConnectCallback`. Everything else (pooling, HTTP/1.1, HTTP/2, decompression) is +the handler's job. What we *do* use: - `System.Net.Sockets.Socket` (raw TCP / UDP, fully managed). @@ -470,5 +488,5 @@ Used by [10_WEB_APIS.md#fetch](10_WEB_APIS.md#fetch). - [ ] Gzip and Brotli-encoded bodies decode byte-identical to non-encoded servers. - [ ] Connection pool reuses a TCP connection across two sequential HTTPS requests to the same origin. - [ ] All of the above pass on Windows, macOS, Linux in CI. -- [ ] `grep -rn 'System.Net.Http\|HttpClient' src/Starling.Net/` is empty (the `HttpClient` ban stands; `SslStream` is now the sanctioned TLS path). -- [ ] `grep -rn 'DllImport\|LibraryImport' src/Starling.Net/` is empty — `Starling.Net` is not a designated interop project. +- [ ] `Starling.Net` builds against `System.Net.Http.HttpClient` — the earlier `HttpClient` ban is lifted (see the status banner). TLS is the BCL's (`SslStream` under `SocketsHttpHandler`), not BouncyCastle. +- [ ] `grep -rn 'DllImport\|LibraryImport' src/Starling.Net/` is still empty — `Starling.Net` writes no P/Invoke itself, even though the `HttpClient` it now calls uses native crypto internally. diff --git a/src/Starling.Net/Dns/DnsCache.cs b/src/Starling.Net/Dns/DnsCache.cs deleted file mode 100644 index 4ef5e9b9..00000000 --- a/src/Starling.Net/Dns/DnsCache.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace Starling.Net.Dns; - -/// -/// TTL-aware LRU cache for DNS lookup results. Thread-safe via a single -/// internal lock; reads and writes are O(1) on average. -/// -public sealed class DnsCache -{ - private readonly int _maxEntries; - private readonly Dictionary _entries; - private readonly LinkedList _lru = new(); - private readonly object _gate = new(); - private readonly Func _now; - - public DnsCache(int maxEntries = 256, Func? now = null) - { - if (maxEntries < 1) - { - throw new ArgumentOutOfRangeException(nameof(maxEntries)); - } - - _maxEntries = maxEntries; - _entries = new(StringComparer.OrdinalIgnoreCase); - _now = now ?? (() => DateTimeOffset.UtcNow); - } - - public bool TryGet(string hostname, out DnsResult result) - { - lock (_gate) - { - if (_entries.TryGetValue(hostname, out var entry) && entry.ExpiresAt > _now()) - { - _lru.Remove(entry.Node); - _lru.AddFirst(entry.Node); - result = entry.Result; - return true; - } - // Expired or absent. - if (_entries.Remove(hostname, out var stale)) - { - _lru.Remove(stale.Node); - } - - result = default!; - return false; - } - } - - public void Put(string hostname, DnsResult result) - { - lock (_gate) - { - if (_entries.TryGetValue(hostname, out var existing)) - { - _lru.Remove(existing.Node); - } - var node = _lru.AddFirst(hostname); - _entries[hostname] = new Entry(result, _now() + result.Ttl, node); - while (_entries.Count > _maxEntries) - { - var oldest = _lru.Last; - if (oldest is null) - { - break; - } - - _entries.Remove(oldest.Value); - _lru.RemoveLast(); - } - } - } - - public int Count - { - get - { - lock (_gate) - { - return _entries.Count; - } - } - } - - private readonly record struct Entry( - DnsResult Result, DateTimeOffset ExpiresAt, LinkedListNode Node); -} diff --git a/src/Starling.Net/Dns/DnsMessage.cs b/src/Starling.Net/Dns/DnsMessage.cs deleted file mode 100644 index d5fcbf4f..00000000 --- a/src/Starling.Net/Dns/DnsMessage.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System.Buffers.Binary; -using System.Text; - -namespace Starling.Net.Dns; - -/// -/// DNS message wire format per RFC 1035 §4.1. Encoder + decoder. -/// -/// -/// Implemented as a static utility so the resolver, the cache, and the test -/// suite can all manipulate raw packets without dragging in the resolver's -/// async / socket dependencies. The decoder is forgiving: malformed records -/// trigger rather than producing partial state. -/// -public static class DnsMessage -{ - public enum QType : ushort { A = 1, NS = 2, CNAME = 5, AAAA = 28 } - public enum QClass : ushort { IN = 1 } - - public enum RCode : byte - { - NoError = 0, - FormatError = 1, - ServerFailure = 2, - NameError = 3, // NXDOMAIN - NotImplemented = 4, - Refused = 5, - } - - public readonly record struct Header( - ushort Id, bool Qr, byte Opcode, bool Aa, bool Tc, bool Rd, bool Ra, - RCode Rcode, ushort QdCount, ushort AnCount, ushort NsCount, ushort ArCount); - - public readonly record struct Question(string Name, QType Type, QClass Class); - - public abstract record Answer(string Name, QType Type, QClass Class, uint Ttl); - public sealed record AAnswer(string Name, QType Type, QClass Class, uint Ttl, - byte[] IPv4) : Answer(Name, Type, Class, Ttl); - public sealed record AaaaAnswer(string Name, QType Type, QClass Class, uint Ttl, - byte[] IPv6) : Answer(Name, Type, Class, Ttl); - public sealed record CNameAnswer(string Name, QType Type, QClass Class, uint Ttl, - string Target) : Answer(Name, Type, Class, Ttl); - public sealed record OtherAnswer(string Name, QType Type, QClass Class, uint Ttl, - byte[] RData) : Answer(Name, Type, Class, Ttl); - - // ----------------------------------------------------------------------- - // Encoder - // ----------------------------------------------------------------------- - - /// - /// Build a standard recursion-desired query for a single (name, qtype, qclass). - /// - public static byte[] BuildQuery(ushort id, string name, QType qtype, QClass qclass = QClass.IN) - { - var nameBytes = EncodeName(name); - var len = 12 + nameBytes.Length + 4; - var buf = new byte[len]; - - // Header — id, flags, counts. - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), id); - // Flags: standard query, RD=1. - buf[2] = 0b0000_0001; - buf[3] = 0; - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 1); // QDCOUNT - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 0); // ANCOUNT - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(8, 2), 0); // NSCOUNT - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(10, 2), 0); // ARCOUNT - - nameBytes.CopyTo(buf, 12); - var off = 12 + nameBytes.Length; - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(off, 2), (ushort)qtype); - BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(off + 2, 2), (ushort)qclass); - return buf; - } - - /// - /// Encode a domain name into the labels-with-length-bytes form. The trailing - /// zero-length root label is included. - /// - public static byte[] EncodeName(string name) - { - if (string.IsNullOrEmpty(name)) - { - return [0]; - } - // Strip trailing dot for consistency. - if (name[^1] == '.') - { - name = name[..^1]; - } - - var labels = name.Split('.'); - var totalLen = 1; // trailing root null - foreach (var label in labels) - { - if (label.Length == 0) - { - throw new FormatException("Empty label in DNS name."); - } - - if (label.Length > 63) - { - throw new FormatException($"Label '{label}' exceeds 63 chars."); - } - - totalLen += 1 + label.Length; - } - if (totalLen > 255) - { - throw new FormatException($"Encoded name '{name}' exceeds 255 bytes."); - } - - var buf = new byte[totalLen]; - var o = 0; - foreach (var label in labels) - { - buf[o++] = (byte)label.Length; - foreach (var ch in label) - { - if (ch >= 0x80) - { - throw new FormatException( - "Non-ASCII labels require IDNA Punycode conversion, which is not implemented yet."); - } - - buf[o++] = (byte)ch; - } - } - buf[o] = 0; // root - return buf; - } - - // ----------------------------------------------------------------------- - // Decoder - // ----------------------------------------------------------------------- - - /// - /// Parse a full DNS response into header + question + answer sections. - /// - public static (Header Header, List Questions, List Answers) - Parse(ReadOnlySpan packet) - { - if (packet.Length < 12) - { - throw new FormatException("Packet shorter than DNS header."); - } - - var id = BinaryPrimitives.ReadUInt16BigEndian(packet[..2]); - var f1 = packet[2]; - var f2 = packet[3]; - var header = new Header( - Id: id, - Qr: (f1 & 0x80) != 0, - Opcode: (byte)((f1 >> 3) & 0xF), - Aa: (f1 & 0x04) != 0, - Tc: (f1 & 0x02) != 0, - Rd: (f1 & 0x01) != 0, - Ra: (f2 & 0x80) != 0, - Rcode: (RCode)(f2 & 0xF), - QdCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(4, 2)), - AnCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(6, 2)), - NsCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(8, 2)), - ArCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(10, 2))); - - var off = 12; - var questions = new List(header.QdCount); - for (var i = 0; i < header.QdCount; i++) - { - var (qname, qoff) = DecodeName(packet, off); - off = qoff; - if (off + 4 > packet.Length) - { - throw new FormatException("Truncated question."); - } - - var qtype = (QType)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off, 2)); - var qclass = (QClass)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 2, 2)); - off += 4; - questions.Add(new Question(qname, qtype, qclass)); - } - - var answers = new List(header.AnCount); - for (var i = 0; i < header.AnCount; i++) - { - var (aname, anoff) = DecodeName(packet, off); - off = anoff; - if (off + 10 > packet.Length) - { - throw new FormatException("Truncated answer."); - } - - var atype = (QType)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off, 2)); - var aclass = (QClass)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 2, 2)); - var ttl = BinaryPrimitives.ReadUInt32BigEndian(packet.Slice(off + 4, 4)); - var rdlen = BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 8, 2)); - off += 10; - if (off + rdlen > packet.Length) - { - throw new FormatException("Truncated rdata."); - } - - Answer ans = atype switch - { - QType.A when rdlen == 4 => - new AAnswer(aname, atype, aclass, ttl, packet.Slice(off, 4).ToArray()), - QType.AAAA when rdlen == 16 => - new AaaaAnswer(aname, atype, aclass, ttl, packet.Slice(off, 16).ToArray()), - QType.CNAME => - new CNameAnswer(aname, atype, aclass, ttl, DecodeName(packet, off).Name), - _ => - new OtherAnswer(aname, atype, aclass, ttl, packet.Slice(off, rdlen).ToArray()), - }; - answers.Add(ans); - off += rdlen; - } - - return (header, questions, answers); - } - - /// - /// Decode a (possibly compressed) name starting at . - /// Returns the dotted name plus the offset immediately after the name. - /// Follows compression pointers per RFC 1035 §4.1.4. - /// - public static (string Name, int NextOffset) DecodeName( - ReadOnlySpan packet, int start) - { - var sb = new StringBuilder(); - var off = start; - int? endOffset = null; - var hops = 0; - while (off < packet.Length) - { - var lenByte = packet[off]; - if (lenByte == 0) - { - off++; - endOffset ??= off; - return (sb.ToString().TrimEnd('.'), endOffset.Value); - } - if ((lenByte & 0xC0) == 0xC0) - { - // Pointer: high 2 bits set; next 14 bits = offset. - if (off + 1 >= packet.Length) - { - throw new FormatException("Truncated pointer."); - } - - var ptr = ((lenByte & 0x3F) << 8) | packet[off + 1]; - if (++hops > 32) - { - throw new FormatException("DNS name compression loop."); - } - - endOffset ??= off + 2; - off = ptr; - continue; - } - if ((lenByte & 0xC0) != 0) - { - throw new FormatException($"Reserved label type 0x{lenByte:X2}."); - } - - off++; - if (off + lenByte > packet.Length) - { - throw new FormatException("Label past end."); - } - - if (sb.Length > 0) - { - sb.Append('.'); - } - - for (var i = 0; i < lenByte; i++) - { - sb.Append((char)packet[off + i]); - } - - off += lenByte; - } - throw new FormatException("Unterminated name."); - } -} diff --git a/src/Starling.Net/Dns/DnsResolver.cs b/src/Starling.Net/Dns/DnsResolver.cs deleted file mode 100644 index 91321c3a..00000000 --- a/src/Starling.Net/Dns/DnsResolver.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Net; -using Starling.Common; - -namespace Starling.Net.Dns; - -/// -/// Pure-managed DNS resolver. Public API: . -/// -/// -/// -/// Short-circuits for the names a resolver shouldn't have to ask the network -/// about — localhost127.0.0.1 + ::1; numeric IPv4 dotted -/// quad → that address. Otherwise queries the supplied -/// , parses the response, follows CNAMEs locally, -/// and caches successful results with their TTL. -/// -/// -/// Cache is bounded by both TTL and entry count (default 256). Resolving the -/// same name within TTL returns immediately. Failed lookups are NOT cached -/// (negative caching deferred). -/// -/// -public sealed class DnsResolver -{ - private readonly IDnsTransport _transport; - private readonly DnsCache _cache; - private readonly Func _newId; - - public DnsResolver(IDnsTransport transport) - : this(transport, new DnsCache(maxEntries: 256), DefaultIdGenerator) { } - - /// Constructor used by tests to seed a deterministic id sequence. - public DnsResolver(IDnsTransport transport, DnsCache cache, Func newId) - { - _transport = transport ?? throw new ArgumentNullException(nameof(transport)); - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _newId = newId ?? throw new ArgumentNullException(nameof(newId)); - } - - private static readonly Random _idRng = Random.Shared; - private static ushort DefaultIdGenerator() => (ushort)_idRng.Next(0, 0x10000); - - public async Task> ResolveAsync( - string hostname, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(hostname)) - { - return Result.Err(DnsError.EmptyHostname); - } - - hostname = hostname.Trim().TrimEnd('.').ToLowerInvariant(); - - // Short-circuit: localhost. - if (hostname == "localhost") - { - return Result.Ok(DnsResult.LoopbackFor(hostname)); - } - - // Short-circuit: numeric IPv4 dotted quad. - if (IPAddress.TryParse(hostname, out var literal)) - { - return Result.Ok(new DnsResult(hostname, [literal], TimeSpan.FromHours(1))); - } - - // Cache. - if (_cache.TryGet(hostname, out var cached)) - { - return Result.Ok(cached); - } - - // Query A + AAAA in parallel. - var aTask = QueryAsync(hostname, DnsMessage.QType.A, ct); - var aaaaTask = QueryAsync(hostname, DnsMessage.QType.AAAA, ct); - await Task.WhenAll(aTask, aaaaTask).ConfigureAwait(false); - - var ips = new List(); - var minTtl = uint.MaxValue; - - foreach (var task in new[] { aTask, aaaaTask }) - { - var result = task.Result; - if (result.Header.Rcode == DnsMessage.RCode.NameError) - { - continue; - } - - if (result.Header.Rcode != DnsMessage.RCode.NoError) - { - continue; - } - - foreach (var a in result.Answers) - { - if (a is DnsMessage.AAnswer av4) - { - ips.Add(new IPAddress(av4.IPv4)); - minTtl = Math.Min(minTtl, av4.Ttl); - } - else if (a is DnsMessage.AaaaAnswer av6) - { - ips.Add(new IPAddress(av6.IPv6)); - minTtl = Math.Min(minTtl, av6.Ttl); - } - // CNAME / Other: ignored for v1 — the recursive resolver - // upstream has already chased the chain and embedded the A/AAAA. - } - } - - if (ips.Count == 0) - { - return Result.Err(DnsError.NoRecords); - } - - var ttl = TimeSpan.FromSeconds(minTtl == uint.MaxValue ? 60 : minTtl); - var result_ = new DnsResult(hostname, ips, ttl); - _cache.Put(hostname, result_); - return Result.Ok(result_); - } - - private async Task<(DnsMessage.Header Header, List Answers)> - QueryAsync(string hostname, DnsMessage.QType qtype, CancellationToken ct) - { - var id = _newId(); - var packet = DnsMessage.BuildQuery(id, hostname, qtype); - byte[] response; - try - { - response = await _transport.SendAsync(packet, ct).ConfigureAwait(false); - } - catch - { - return (default, []); - } - try - { - var (header, _, answers) = DnsMessage.Parse(response); - return (header, answers); - } - catch (FormatException) - { - return (default, []); - } - } -} - -public enum DnsError -{ - EmptyHostname, - NoRecords, - Timeout, - TransportFailure, -} - -public sealed record DnsResult(string Hostname, IReadOnlyList Addresses, TimeSpan Ttl) -{ - public static DnsResult LoopbackFor(string hostname) => new( - hostname, - [IPAddress.Loopback, IPAddress.IPv6Loopback], - TimeSpan.FromHours(1)); -} diff --git a/src/Starling.Net/Dns/IDnsTransport.cs b/src/Starling.Net/Dns/IDnsTransport.cs deleted file mode 100644 index 62ed702c..00000000 --- a/src/Starling.Net/Dns/IDnsTransport.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Starling.Net.Dns; - -/// -/// Transport seam for the DNS resolver. Sends a query packet, returns the -/// response. The production implementation is ; -/// tests substitute a fake to drive specific canned responses. -/// -public interface IDnsTransport -{ - Task SendAsync(byte[] queryPacket, CancellationToken ct); -} diff --git a/src/Starling.Net/Dns/UdpDnsTransport.cs b/src/Starling.Net/Dns/UdpDnsTransport.cs deleted file mode 100644 index 70fec061..00000000 --- a/src/Starling.Net/Dns/UdpDnsTransport.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Net; -using System.Net.Sockets; - -namespace Starling.Net.Dns; - -/// -/// Real UDP transport for DNS queries. Sends to a configured resolver IP:port -/// (default 8.8.8.8:53 — Google Public DNS). Pure managed via -/// per Rule 0. -/// -/// -/// Single round-trip; no retransmit, no fallback to TCP. Adequate for v1 -/// hostnames whose responses fit in 512 bytes (typical A/AAAA records). -/// Larger responses (DNSSEC, many records) will require TC=1 handling + -/// TCP fallback per RFC 1035 §4.2.2 — deferred to a follow-up. -/// -public sealed class UdpDnsTransport : IDnsTransport -{ - public IPEndPoint Resolver { get; } - public TimeSpan Timeout { get; } - - public UdpDnsTransport() - : this(new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53), TimeSpan.FromSeconds(5)) { } - - public UdpDnsTransport(IPEndPoint resolver, TimeSpan timeout) - { - Resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); - Timeout = timeout; - } - - public async Task SendAsync(byte[] queryPacket, CancellationToken ct) - { - using var client = new UdpClient(AddressFamily.InterNetwork); - // Bound timeout — if the resolver doesn't answer, we surface the - // cancellation as OperationCanceledException to the caller. - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(Timeout); - - await client.SendAsync(queryPacket, queryPacket.Length, Resolver) - .WaitAsync(cts.Token).ConfigureAwait(false); - var result = await client.ReceiveAsync(cts.Token).ConfigureAwait(false); - return result.Buffer; - } -} diff --git a/src/Starling.Net/Http/ConnectionPool.cs b/src/Starling.Net/Http/ConnectionPool.cs deleted file mode 100644 index 86e03477..00000000 --- a/src/Starling.Net/Http/ConnectionPool.cs +++ /dev/null @@ -1,286 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Starling.Net.Http; - -internal static partial class ConnectionPoolLog -{ - [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during pool discard")] - public static partial void DiscardFailed(ILogger logger, Exception ex); -} - -/// -/// Per-origin pool of idle, kept-alive HTTP/1.1 transports. Keyed on -/// (scheme/host/port). LRU-bounded, with an idle -/// timeout that prunes long-quiet connections before they would be killed -/// by upstream NATs or server-side reapers. -/// -/// -/// Sizing rationale: 6 idle connections per origin matches the classic -/// HTTP/1.1 browser concurrency cap (Chrome / Firefox 6, Safari 6). -/// Idle timeout default of 60s is the value Chromium has used since the -/// early Blink era; most production HTTP/1.1 servers (nginx, apache, IIS) -/// have a server-side keep-alive timeout in the 5–120s range, and 60s sits -/// comfortably under the upper bound while letting bursty page loads reuse -/// connections. -/// -/// Eviction policy: oldest by last-used time. When the pool is full and a -/// new release arrives, the oldest is dequeued and disposed before the new -/// one is pushed in. -/// -/// -/// Thread safety: , , -/// and are all safe -/// to call concurrently. Each origin owns its own queue under a per-origin -/// lock so requests against different origins don't contend. -/// -/// -public sealed class ConnectionPool : IAsyncDisposable -{ - /// Default per-origin idle capacity (HTTP/1.1 browser cap). - public const int DefaultMaxPerOrigin = 6; - - /// Default idle timeout matching Chromium's historical default. - public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromSeconds(60); - - private readonly Dictionary> _byOrigin = []; - private readonly object _gate = new(); - private readonly ILogger _log; - private bool _disposed; - - public int MaxPerOrigin { get; } - public TimeSpan IdleTimeout { get; } - - public ConnectionPool() : this(DefaultMaxPerOrigin, DefaultIdleTimeout) { } - - public ConnectionPool(int maxPerOrigin, TimeSpan idleTimeout, ILogger? log = null) - { - if (maxPerOrigin < 1) - { - throw new ArgumentOutOfRangeException( - nameof(maxPerOrigin), "Pool capacity must be at least 1."); - } - - if (idleTimeout <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException( - nameof(idleTimeout), "Idle timeout must be positive."); - } - - MaxPerOrigin = maxPerOrigin; - IdleTimeout = idleTimeout; - _log = log ?? NullLogger.Instance; - } - - /// - /// Snapshot of the idle-transport count across all origins. Mainly for - /// tests; not used on the request path. - /// - public int IdleCount - { - get - { - lock (_gate) - { - var n = 0; - foreach (var q in _byOrigin.Values) - { - n += q.Count; - } - - return n; - } - } - } - - /// - /// Idle count for one specific origin. Useful for tests asserting on - /// per-host pool occupancy. - /// - public int IdleCountFor(OriginKey origin) - { - lock (_gate) - { - return _byOrigin.TryGetValue(origin, out var q) ? q.Count : 0; - } - } - - /// - /// Attempt to acquire an idle transport for the given origin. Returns the - /// most-recently-used (MRU) transport — newer connections are likely to - /// still be open at the server. - /// - public IHttpTransport? TryAcquire(OriginKey origin) - { - lock (_gate) - { - if (_disposed) - { - return null; - } - - if (!_byOrigin.TryGetValue(origin, out var q) || q.Count == 0) - { - return null; - } - - // MRU: take from the tail. Discard any that have since closed - // (e.g., peer FIN we haven't noticed yet) and try the next. - while (q.Count > 0) - { - var node = q.Last!; - q.RemoveLast(); - if (node.Value.Transport.IsOpen) - { - return node.Value.Transport; - } - - // Stale: dispose and keep looking. - _ = DiscardAsync(node.Value.Transport, _log); - } - return null; - } - } - - /// - /// Return a kept-alive transport to the pool. Caller must ensure the - /// response body was fully consumed and both sides agreed to keep the - /// connection alive. If the pool is at capacity for the origin, the - /// oldest entry is evicted and disposed (LRU). - /// - public async ValueTask ReleaseAsync(IHttpTransport transport) - { - ArgumentNullException.ThrowIfNull(transport); - - IHttpTransport? evicted = null; - lock (_gate) - { - if (_disposed || !transport.IsOpen) - { - // Caller asked to release a broken transport, or the pool was - // disposed while we were holding the connection. Just close it. - } - else - { - var q = GetQueue(transport.Origin); - if (q.Count >= MaxPerOrigin) - { - // LRU eviction: drop the oldest entry to make room. - evicted = q.First!.Value.Transport; - q.RemoveFirst(); - } - q.AddLast(new Entry(transport, DateTimeOffset.UtcNow)); - transport = null!; // consumed by the pool; do not dispose below - } - } - - if (transport is not null) - { - await DiscardAsync(transport, _log).ConfigureAwait(false); - } - - if (evicted is not null) - { - await DiscardAsync(evicted, _log).ConfigureAwait(false); - } - } - - /// - /// Drop and dispose any pooled transport whose last-used timestamp is - /// older than minus . When - /// is null, is - /// used. - /// - /// Count of transports drained. - public async ValueTask DrainExpiredAsync(DateTimeOffset? now = null) - { - var threshold = (now ?? DateTimeOffset.UtcNow) - IdleTimeout; - List? expired = null; - - lock (_gate) - { - foreach (var (_, q) in _byOrigin) - { - // Entries are appended in arrival order, which is also - // last-used order — keep popping from the front while stale. - while (q.First is { } first && first.Value.LastUsed <= threshold) - { - (expired ??= []).Add(first.Value.Transport); - q.RemoveFirst(); - } - } - } - - if (expired is null) - { - return 0; - } - - foreach (var t in expired) - { - await DiscardAsync(t, _log).ConfigureAwait(false); - } - - return expired.Count; - } - - /// - /// Drop and dispose every pooled transport. The pool itself stays usable - /// (e.g. for test scenarios that drain between assertions); call - /// to additionally mark it as no longer - /// accepting new releases. - /// - public async ValueTask DisposeAllAsync() - { - List? toClose = null; - lock (_gate) - { - foreach (var (_, q) in _byOrigin) - { - foreach (var entry in q) - { - (toClose ??= []).Add(entry.Transport); - } - - q.Clear(); - } - _byOrigin.Clear(); - } - if (toClose is null) - { - return; - } - - foreach (var t in toClose) - { - await DiscardAsync(t, _log).ConfigureAwait(false); - } - } - - public async ValueTask DisposeAsync() - { - await DisposeAllAsync().ConfigureAwait(false); - lock (_gate) - { - _disposed = true; - } - } - - private LinkedList GetQueue(OriginKey origin) - { - if (!_byOrigin.TryGetValue(origin, out var q)) - { - q = new LinkedList(); - _byOrigin[origin] = q; - } - return q; - } - - private static async ValueTask DiscardAsync(IHttpTransport transport, ILogger log) - { - try { await transport.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { ConnectionPoolLog.DiscardFailed(log, ex); /* a stale socket may throw on shutdown; pooling doesn't care */ } - } - - private readonly record struct Entry(IHttpTransport Transport, DateTimeOffset LastUsed); -} diff --git a/src/Starling.Net/Http/Decoding/BodyDecoder.cs b/src/Starling.Net/Http/Decoding/BodyDecoder.cs deleted file mode 100644 index 2067f5e9..00000000 --- a/src/Starling.Net/Http/Decoding/BodyDecoder.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.IO.Compression; - -namespace Starling.Net.Http.Decoding; - -/// -/// Applies HTTP Content-Encoding decoding stages to a buffered body. -/// -/// -/// Per RFC 9110 §8.4, encodings in Content-Encoding are listed in the -/// order they were applied. To recover the identity representation we apply -/// the inverse codings in reverse order — i.e. the last listed coding is -/// peeled first. -/// -public static class BodyDecoder -{ - /// - /// Decode through every stage named in - /// . Unknown or empty encoding tokens - /// are rejected. - /// - public static byte[] Decode(ReadOnlyMemory body, IReadOnlyList contentEncodings) - { - ArgumentNullException.ThrowIfNull(contentEncodings); - - if (contentEncodings.Count == 0) - { - return body.ToArray(); - } - - var current = body.ToArray(); - for (var i = contentEncodings.Count - 1; i >= 0; i--) - { - current = DecodeStage(current, contentEncodings[i]); - } - return current; - } - - /// - /// Parse a comma-separated Content-Encoding header value into a - /// list of lowercase coding tokens. Whitespace and empty entries are - /// stripped. identity is filtered out (it's a no-op coding). - /// - public static IReadOnlyList ParseEncodings(string? headerValue) - { - if (string.IsNullOrWhiteSpace(headerValue)) - { - return Array.Empty(); - } - - var parts = headerValue.Split(','); - List? result = null; - foreach (var raw in parts) - { - var token = raw.Trim().ToLowerInvariant(); - if (token.Length == 0 || token == "identity") - { - continue; - } - - (result ??= []).Add(token); - } - return result ?? (IReadOnlyList)Array.Empty(); - } - - private static byte[] DecodeStage(byte[] input, string encoding) - { - var token = (encoding ?? string.Empty).Trim().ToLowerInvariant(); - return token switch - { - "gzip" or "x-gzip" => Decompress(input, raw => new GZipStream(raw, CompressionMode.Decompress)), - "br" => Decompress(input, raw => new BrotliStream(raw, CompressionMode.Decompress)), - "deflate" => DecodeDeflate(input), - "identity" or "" => input, - _ => throw new NotSupportedException($"Content-Encoding '{encoding}' is not supported."), - }; - } - - private static byte[] Decompress(byte[] input, Func wrap) - { - using var raw = new MemoryStream(input, writable: false); - using var decompressor = wrap(raw); - using var output = new MemoryStream(); - decompressor.CopyTo(output); - return output.ToArray(); - } - - /// - /// "deflate" in HTTP is historically zlib-wrapped DEFLATE (RFC 1950), - /// but real servers send raw DEFLATE about as often. Try zlib first; if - /// the header fails, fall back to raw . - /// - private static byte[] DecodeDeflate(byte[] input) - { - try - { - return Decompress(input, raw => new ZLibStream(raw, CompressionMode.Decompress)); - } - catch (InvalidDataException) - { - return Decompress(input, raw => new DeflateStream(raw, CompressionMode.Decompress)); - } - } -} diff --git a/src/Starling.Net/Http/Decoding/ChunkedReader.cs b/src/Starling.Net/Http/Decoding/ChunkedReader.cs deleted file mode 100644 index 6c5d15ba..00000000 --- a/src/Starling.Net/Http/Decoding/ChunkedReader.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Globalization; -using System.Text; - -namespace Starling.Net.Http.Decoding; - -/// -/// Decoder for HTTP/1.1 Transfer-Encoding: chunked framing per -/// RFC 9112 §7.1. Drains an until the -/// terminating zero-sized chunk (and any trailers) is consumed. -/// -internal static class ChunkedReader -{ - private const int MaxChunkSizeLineLength = 1024; - private const int MaxTrailerLineLength = 8 * 1024; - - public static async ValueTask ReadAllAsync( - InboundBuffer source, int maxBodyBytes, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(source); - if (maxBodyBytes < 0) - { - throw new ArgumentOutOfRangeException(nameof(maxBodyBytes)); - } - - using var ms = new MemoryStream(); - - while (true) - { - var sizeLine = await source.TakeLineAsync(MaxChunkSizeLineLength, ct).ConfigureAwait(false) - ?? throw new InvalidDataException("Unexpected EOF before chunk size line."); - - var size = ParseChunkSize(sizeLine); - - if (size == 0) - { - // Drain trailer section: zero or more header lines followed by an empty line. - while (true) - { - var trailer = await source.TakeLineAsync(MaxTrailerLineLength, ct).ConfigureAwait(false) - ?? throw new InvalidDataException("Unexpected EOF inside chunked trailers."); - if (trailer.Length == 0) - { - break; - } - // v1: trailers ignored. - } - break; - } - - if (ms.Length + size > maxBodyBytes) - { - throw new InvalidDataException("Chunked body exceeded cap."); - } - - var chunk = await source.ReadExactAsync(size, ct).ConfigureAwait(false); - ms.Write(chunk); - - var crlf = await source.TakeLineAsync(2, ct).ConfigureAwait(false) - ?? throw new InvalidDataException("Unexpected EOF after chunk data."); - if (crlf.Length != 0) - { - throw new InvalidDataException("Expected CRLF terminator after chunk data."); - } - } - - return ms.ToArray(); - } - - /// - /// Parse a chunk-size line per §7.1.1. Form: 1*HEXDIG [chunk-ext]. - /// We accept hex digits in either case and ignore everything from the - /// first ';' onwards (chunk extensions). - /// - internal static int ParseChunkSize(byte[] line) - { - ArgumentNullException.ThrowIfNull(line); - if (line.Length == 0) - { - throw new InvalidDataException("Chunk-size line was empty."); - } - - var end = line.Length; - for (var i = 0; i < line.Length; i++) - { - if (line[i] == (byte)';' || line[i] == (byte)' ' || line[i] == (byte)'\t') - { - end = i; - break; - } - } - - if (end == 0) - { - throw new InvalidDataException("Chunk-size line had no hex digits."); - } - - var asAscii = Encoding.ASCII.GetString(line, 0, end); - if (!int.TryParse(asAscii, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var size) - || size < 0) - { - throw new InvalidDataException($"Chunk-size '{asAscii}' is not a valid hex integer."); - } - return size; - } -} diff --git a/src/Starling.Net/Http/Decoding/InboundBuffer.cs b/src/Starling.Net/Http/Decoding/InboundBuffer.cs deleted file mode 100644 index 0e18c737..00000000 --- a/src/Starling.Net/Http/Decoding/InboundBuffer.cs +++ /dev/null @@ -1,233 +0,0 @@ -namespace Starling.Net.Http.Decoding; - -/// -/// Pull-buffered reader over a . Used by the response parser -/// to scan for CRLF-terminated lines and then drain a known number of body -/// bytes (or read to EOF) without re-issuing tiny reads against the network. -/// -/// -/// The buffer grows on demand up to the caller-bounded line/header/body caps. -/// All public read methods are async; cancellation propagates from the -/// underlying stream. -/// -internal sealed class InboundBuffer -{ - private const int InitialCapacity = 8 * 1024; - private readonly Stream _stream; - private byte[] _buf; - private int _start; - private int _end; - - public InboundBuffer(Stream stream) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _buf = new byte[InitialCapacity]; - } - - public bool Eof { get; private set; } - public int BufferedCount => _end - _start; - - public ReadOnlySpan Peek() => _buf.AsSpan(_start, _end - _start); - - public void Consume(int count) - { - if (count < 0 || count > BufferedCount) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - - _start += count; - if (_start == _end) { _start = 0; _end = 0; } - } - - /// - /// Read more bytes from the underlying stream into the buffer. Returns - /// false when the stream has signalled EOF. - /// - public async ValueTask ReadMoreAsync(CancellationToken ct) - { - if (Eof) - { - return false; - } - - EnsureCapacityForMore(); - var n = await _stream.ReadAsync(_buf.AsMemory(_end, _buf.Length - _end), ct) - .ConfigureAwait(false); - if (n == 0) { Eof = true; return false; } - _end += n; - return true; - } - - /// - /// Locate a "\r\n" sequence inside the currently buffered region. Returns - /// the relative offset of the '\r' or -1 when no CRLF is present yet. - /// - public int IndexOfCrLf() - { - var span = Peek(); - for (var i = 0; i + 1 < span.Length; i++) - { - if (span[i] == 0x0D && span[i + 1] == 0x0A) - { - return i; - } - } - return -1; - } - - /// - /// Locate a "\r\n\r\n" sequence inside the currently buffered region. - /// Returns the relative offset of the first '\r' or -1. - /// - public int IndexOfDoubleCrLf() - { - var span = Peek(); - for (var i = 0; i + 3 < span.Length; i++) - { - if (span[i] == 0x0D && span[i + 1] == 0x0A - && span[i + 2] == 0x0D && span[i + 3] == 0x0A) - { - return i; - } - } - return -1; - } - - /// - /// Read a CRLF-terminated line. The returned span is invalidated by the - /// next mutating call (read/consume); copy if you need to retain it. - /// - public async ValueTask ReadLineAsync(int maxLineLength, CancellationToken ct) - { - while (true) - { - var idx = IndexOfCrLf(); - if (idx >= 0) - { - return true; - } - - if (BufferedCount > maxLineLength) - { - return false; - } - - if (!await ReadMoreAsync(ct).ConfigureAwait(false)) - { - return false; - } - } - } - - /// - /// Take a CRLF-terminated line as a copied byte array (without the CRLF). - /// Returns null on EOF before a full line was assembled. - /// - public async ValueTask TakeLineAsync(int maxLineLength, CancellationToken ct) - { - if (!await ReadLineAsync(maxLineLength, ct).ConfigureAwait(false)) - { - // Either EOF or oversized line. Distinguish via Eof flag — caller may want to retry. - return null; - } - var idx = IndexOfCrLf(); - var line = Peek().Slice(0, idx).ToArray(); - Consume(idx + 2); - return line; - } - - /// - /// Read exactly bytes (consuming buffered bytes - /// first, then reading from the stream). Returns the result as a new array. - /// - public async ValueTask ReadExactAsync(int count, CancellationToken ct) - { - if (count < 0) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - - var result = new byte[count]; - var written = 0; - - if (BufferedCount > 0) - { - var take = Math.Min(BufferedCount, count); - _buf.AsSpan(_start, take).CopyTo(result.AsSpan(0, take)); - Consume(take); - written = take; - } - - while (written < count) - { - var n = await _stream - .ReadAsync(result.AsMemory(written, count - written), ct) - .ConfigureAwait(false); - if (n == 0) - { - Eof = true; - throw new EndOfStreamException( - $"Stream ended after {written} of {count} expected bytes."); - } - written += n; - } - return result; - } - - /// - /// Read everything remaining (buffered + stream) until EOF, capped at - /// . Throws if the cap is exceeded. - /// - public async ValueTask ReadToEndAsync(int maxBytes, CancellationToken ct) - { - using var ms = new MemoryStream(); - if (BufferedCount > 0) - { - if (BufferedCount > maxBytes) - { - throw new InvalidDataException("Body exceeded cap."); - } - - ms.Write(_buf, _start, BufferedCount); - Consume(BufferedCount); - } - - var temp = new byte[8 * 1024]; - while (true) - { - if (ms.Length > maxBytes) - { - throw new InvalidDataException("Body exceeded cap."); - } - - var n = await _stream.ReadAsync(temp, ct).ConfigureAwait(false); - if (n == 0) { Eof = true; break; } - if (ms.Length + n > maxBytes) - { - throw new InvalidDataException("Body exceeded cap."); - } - - ms.Write(temp, 0, n); - } - return ms.ToArray(); - } - - private void EnsureCapacityForMore() - { - if (_start > 0 && _end - _start < _buf.Length / 2) - { - Buffer.BlockCopy(_buf, _start, _buf, 0, _end - _start); - _end -= _start; - _start = 0; - } - if (_end == _buf.Length) - { - var grown = new byte[_buf.Length * 2]; - Buffer.BlockCopy(_buf, _start, grown, 0, _end - _start); - _end -= _start; - _start = 0; - _buf = grown; - } - } -} diff --git a/src/Starling.Net/Http/H1/H1RequestWriter.cs b/src/Starling.Net/Http/H1/H1RequestWriter.cs deleted file mode 100644 index cdc871af..00000000 --- a/src/Starling.Net/Http/H1/H1RequestWriter.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Buffers; -using System.Globalization; -using System.Text; -using StarlingUrl = global::Starling.Url.Url; - -namespace Starling.Net.Http.H1; - -/// -/// Serializes a into a wire-format HTTP/1.1 message -/// (request-line + headers + body) and writes it to a . -/// -/// -/// The writer fills in the spec-required headers (Host, User-Agent, Accept, -/// Accept-Encoding, Connection, Content-Length) only when the caller has not -/// already supplied them. Header names from -/// always win — this lets callers force a header that conflicts with our -/// defaults (e.g. Accept-Encoding: identity for testing). -/// -public sealed class H1RequestWriter -{ - public string UserAgent { get; init; } = "Starling/0.1 (https://github.com/anthropic-starling)"; - - public string AcceptHeader { get; init; } = - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; - - public string AcceptEncodingHeader { get; init; } = "gzip, br, deflate"; - - public string ConnectionHeader { get; init; } = "keep-alive"; - - public async ValueTask WriteAsync(HttpRequest req, Stream output, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(req); - ArgumentNullException.ThrowIfNull(output); - - var rent = ArrayPool.Shared.Rent(4096); - try - { - var bytes = Serialize(req, rent); - await output.WriteAsync(bytes, ct).ConfigureAwait(false); - - if (!req.Body.IsEmpty) - { - await output.WriteAsync(req.Body, ct).ConfigureAwait(false); - } - - await output.FlushAsync(ct).ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(rent); - } - } - - /// - /// Test-friendly synchronous serialization. Returns the wire bytes for the - /// request-line + header block (without the body) so unit tests can assert - /// the textual form without standing up a stream. - /// - public byte[] SerializeHead(HttpRequest req) - { - ArgumentNullException.ThrowIfNull(req); - var ms = new MemoryStream(); - var head = Serialize(req, scratch: null); - ms.Write(head.Span); - return ms.ToArray(); - } - - private ReadOnlyMemory Serialize(HttpRequest req, byte[]? scratch) - { - var sb = new StringBuilder(512); - - sb.Append(req.Method).Append(' ') - .Append(BuildRequestTarget(req.Url)).Append(' ') - .Append("HTTP/1.1\r\n"); - - AppendDefaultHeader(sb, req.Headers, "Host", BuildHostHeader(req.Url)); - AppendDefaultHeader(sb, req.Headers, "User-Agent", UserAgent); - AppendDefaultHeader(sb, req.Headers, "Accept", AcceptHeader); - AppendDefaultHeader(sb, req.Headers, "Accept-Encoding", AcceptEncodingHeader); - AppendDefaultHeader(sb, req.Headers, "Connection", ConnectionHeader); - - // Emit Content-Length when there is a body, OR when the method is one - // that carries a request body (POST/PUT/PATCH) even if that body is - // empty. An empty-body POST with no Content-Length is rejected by many - // servers with 411 Length Required — XHR/fetch always send "0" here, so - // we must too (e.g. McMaster's token-authorization POST). - if ((!req.Body.IsEmpty || MethodCarriesBody(req.Method)) - && !req.Headers.Contains("Content-Length") - && !req.Headers.Contains("Transfer-Encoding")) - { - sb.Append("Content-Length: ") - .Append(req.Body.Length.ToString(CultureInfo.InvariantCulture)) - .Append("\r\n"); - } - - foreach (var kv in req.Headers) - { - sb.Append(kv.Key).Append(": ").Append(kv.Value).Append("\r\n"); - } - - sb.Append("\r\n"); - - var s = sb.ToString(); - var len = Encoding.ASCII.GetByteCount(s); - if (scratch is null || scratch.Length < len) - { - scratch = new byte[len]; - } - - var written = Encoding.ASCII.GetBytes(s, scratch); - return new ReadOnlyMemory(scratch, 0, written); - } - - /// True for request methods that carry a body (so an empty body - /// still warrants Content-Length: 0). GET/HEAD/OPTIONS/etc. do not. - internal static bool MethodCarriesBody(string method) => - method.Equals("POST", StringComparison.OrdinalIgnoreCase) || - method.Equals("PUT", StringComparison.OrdinalIgnoreCase) || - method.Equals("PATCH", StringComparison.OrdinalIgnoreCase); - - private static void AppendDefaultHeader(StringBuilder sb, HttpHeaders user, string name, string value) - { - if (user.Contains(name)) - { - return; - } - - sb.Append(name).Append(": ").Append(value).Append("\r\n"); - } - - /// - /// Build the request-target per RFC 9112 §3.2. For origin-form (the only - /// form we emit for direct connections, not proxies) that is the path - /// plus the query, with the path defaulting to "/" if empty. - /// - internal static string BuildRequestTarget(StarlingUrl url) - { - var path = string.IsNullOrEmpty(url.Path) ? "/" : url.Path; - if (!path.StartsWith('/')) - { - path = "/" + path; - } - - return url.Query is { Length: > 0 } q ? path + "?" + q : path; - } - - /// - /// Build the Host header per RFC 9112 §3.2 / §7.2. Includes the explicit - /// port if the URL specifies one that differs from the scheme default. - /// - internal static string BuildHostHeader(StarlingUrl url) - { - if (string.IsNullOrEmpty(url.Host)) - { - throw new ArgumentException("URL has no host — cannot build Host header.", nameof(url)); - } - - var defaultPort = url.DefaultPort; - if (url.Port is int p && p != defaultPort) - { - return url.Host + ":" + p.ToString(CultureInfo.InvariantCulture); - } - - return url.Host; - } -} diff --git a/src/Starling.Net/Http/H1/H1ResponseParser.cs b/src/Starling.Net/Http/H1/H1ResponseParser.cs deleted file mode 100644 index cfa3e82b..00000000 --- a/src/Starling.Net/Http/H1/H1ResponseParser.cs +++ /dev/null @@ -1,345 +0,0 @@ -using System.Globalization; -using System.Text; -using Starling.Common; -using Starling.Net.Http.Decoding; - -namespace Starling.Net.Http.H1; - -/// -/// Parser for an HTTP/1.1 response message off a byte-oriented -/// . Returns a fully buffered -/// with body framing (Content-Length / chunked / EOF) and any -/// Content-Encoding stack removed. -/// -/// -/// State machine — RFC 9112 §3: -/// status-line → header-section → body -/// Body framing decided per §6.3: -/// 1. Transfer-Encoding: chunked takes priority over Content-Length. -/// 2. Content-Length: N if present. -/// 3. Otherwise read until EOF (legacy HTTP/1.0 close-delimited). -/// We do not attempt to handle 1xx informational responses except -/// to discard their head and re-enter the status-line state; v1 wires the -/// HTTP layer to a TLS transport that we don't drive in 100-Continue mode. -/// -public sealed class H1ResponseParser -{ - /// Cap on the size of the status-line + header block. - public int MaxHeaderBlockBytes { get; init; } = 64 * 1024; - - /// Cap on the decoded body (post Content-Encoding). - public int MaxBodyBytes { get; init; } = 32 * 1024 * 1024; - - /// Cap on a single header line. - public int MaxHeaderLineBytes { get; init; } = 16 * 1024; - - public async Task> ParseAsync( - Stream input, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(input); - - var buf = new InboundBuffer(input); - - // 1. Discard 1xx informational responses (e.g. 100 Continue / 103 Early Hints). - Headline headline; - HttpHeaders headers; - while (true) - { - var headBlock = await ReadHeaderBlockAsync(buf, ct).ConfigureAwait(false); - if (headBlock.IsErr) - { - return Result.Err(headBlock.Error); - } - - var parsed = ParseHeadBlock(headBlock.Value); - if (parsed.IsErr) - { - return Result.Err(parsed.Error); - } - - headline = parsed.Value.Headline; - headers = parsed.Value.Headers; - if (headline.StatusCode is < 100 or >= 200) - { - break; - } - // 1xx — keep reading. - } - - // 2. Body framing. - byte[] rawBody; - try - { - rawBody = await ReadBodyAsync(buf, headline, headers, ct).ConfigureAwait(false); - } - catch (InvalidDataException ex) when (ex.Message.Contains("exceeded", StringComparison.Ordinal)) - { - return Result.Err(HttpError.BodyTooLarge); - } - catch (InvalidDataException) - { - return Result.Err(HttpError.BadChunkedFraming); - } - catch (EndOfStreamException) - { - return Result.Err(HttpError.UnexpectedEof); - } - - // 3. Content-Encoding. - byte[] decoded; - try - { - var encodings = BodyDecoder.ParseEncodings(headers.GetFirst("Content-Encoding")); - decoded = encodings.Count == 0 ? rawBody : BodyDecoder.Decode(rawBody, encodings); - } - catch (NotSupportedException) - { - return Result.Err(HttpError.UnsupportedEncoding); - } - catch (InvalidDataException) - { - return Result.Err(HttpError.DecodeFailed); - } - - return Result.Ok( - new HttpResponse( - headline.HttpVersion, - headline.StatusCode, - headline.ReasonPhrase, - headers, - decoded)); - } - - private async Task> ReadHeaderBlockAsync( - InboundBuffer buf, CancellationToken ct) - { - while (true) - { - var idx = buf.IndexOfDoubleCrLf(); - if (idx >= 0) - { - var headBytes = buf.Peek().Slice(0, idx + 2).ToArray(); // include the first CRLF; not the empty line - buf.Consume(idx + 4); - return Result.Ok(headBytes); - } - if (buf.BufferedCount > MaxHeaderBlockBytes) - { - return Result.Err(HttpError.HeadersTooLarge); - } - - if (!await buf.ReadMoreAsync(ct).ConfigureAwait(false)) - { - if (buf.BufferedCount == 0) - { - return Result.Err(HttpError.UnexpectedEof); - } - - return Result.Err(HttpError.UnexpectedEof); - } - } - } - - private Result<(Headline Headline, HttpHeaders Headers), HttpError> ParseHeadBlock(byte[] head) - { - var text = Encoding.ASCII.GetString(head); - // We included the trailing CRLF before the empty line, so the last - // newline is the one ending the final header line. - var lines = text.Split("\r\n"); - if (lines.Length == 0 || string.IsNullOrEmpty(lines[0])) - { - return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadStatusLine); - } - - var headlineResult = ParseStatusLine(lines[0]); - if (headlineResult.IsErr) - { - return Result<(Headline, HttpHeaders), HttpError>.Err(headlineResult.Error); - } - - var headers = new HttpHeaders(); - for (var i = 1; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Length == 0) - { - continue; - } - - if (line[0] is ' ' or '\t') - { - // RFC 7230 §3.2.4: line folding is deprecated and MUST be rejected. - return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader); - } - - var colon = line.IndexOf(':', StringComparison.Ordinal); - if (colon <= 0) - { - return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader); - } - - var name = line[..colon]; - var value = line[(colon + 1)..].Trim(' ', '\t'); - - try { headers.Add(name, value); } - catch (ArgumentException) { return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader); } - } - - return Result<(Headline, HttpHeaders), HttpError>.Ok((headlineResult.Value, headers)); - } - - private static Result ParseStatusLine(string line) - { - // status-line = HTTP-version SP status-code SP [ reason-phrase ] - var firstSp = line.IndexOf(' ', StringComparison.Ordinal); - if (firstSp <= 0) - { - return Result.Err(HttpError.BadStatusLine); - } - - var version = line[..firstSp]; - if (!version.StartsWith("HTTP/", StringComparison.Ordinal)) - { - return Result.Err(HttpError.BadStatusLine); - } - - var secondSp = line.IndexOf(' ', firstSp + 1); - var codeStr = secondSp < 0 ? line[(firstSp + 1)..] : line[(firstSp + 1)..secondSp]; - if (!int.TryParse(codeStr, NumberStyles.None, CultureInfo.InvariantCulture, out var code) - || code is < 100 or > 599) - { - return Result.Err(HttpError.BadStatusLine); - } - - var reason = secondSp < 0 ? string.Empty : line[(secondSp + 1)..]; - - return Result.Ok(new Headline(version, code, reason)); - } - - private async Task ReadBodyAsync( - InboundBuffer buf, - Headline headline, - HttpHeaders headers, - CancellationToken ct) - { - // 204/304 and HEAD responses must have an empty body, but we don't - // know the request method here. RFC 9112 §6.3 step 1 covers status — - // the rest is the caller's responsibility. - if (headline.StatusCode is 204 or 304) - { - return Array.Empty(); - } - - var te = headers.GetFirst("Transfer-Encoding"); - if (te is not null && ContainsToken(te, "chunked")) - { - return await ChunkedReader.ReadAllAsync(buf, MaxBodyBytes, ct).ConfigureAwait(false); - } - - var clText = headers.GetFirst("Content-Length"); - if (clText is not null) - { - if (!long.TryParse(clText, NumberStyles.None, CultureInfo.InvariantCulture, out var cl) || cl < 0) - { - throw new InvalidDataException("Bad Content-Length"); - } - - if (cl > MaxBodyBytes) - { - throw new InvalidDataException("Content-Length exceeded cap."); - } - - return cl == 0 ? Array.Empty() : await buf.ReadExactAsync((int)cl, ct).ConfigureAwait(false); - } - - return await buf.ReadToEndAsync(MaxBodyBytes, ct).ConfigureAwait(false); - } - - private static bool ContainsToken(string headerValue, string token) - { - foreach (var raw in headerValue.Split(',')) - { - if (string.Equals(raw.Trim(), token, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - - /// - /// Decide whether indicated the server is - /// willing to keep the underlying transport open for another request on - /// the same origin. Used by the connection pool to gate - /// ReleaseAsync on a clean response. - /// - /// - /// RFC 9112 §9.3: HTTP/1.1 connections are keep-alive by default unless - /// a Connection: close option is present. HTTP/1.0 connections are - /// close-by-default unless the legacy Connection: keep-alive - /// signal is present (RFC 7230 §A.1.2). A response framed by - /// connection-close (no Content-Length / Transfer-Encoding on a non-empty - /// body) cannot be pooled even if the headers say keep-alive — the - /// caller is responsible for checking framing. - /// - public static bool IndicatesKeepAlive(HttpResponse response) - { - ArgumentNullException.ThrowIfNull(response); - - var connection = response.Headers.GetFirst("Connection"); - var isHttp11 = string.Equals(response.HttpVersion, "HTTP/1.1", StringComparison.Ordinal); - var isHttp10 = string.Equals(response.HttpVersion, "HTTP/1.0", StringComparison.Ordinal); - - if (connection is not null) - { - if (ContainsToken(connection, "close")) - { - return false; - } - - if (ContainsToken(connection, "keep-alive")) - { - return true; - } - } - - // No explicit signal: HTTP/1.1 default keep-alive; HTTP/1.0 default close. - if (isHttp11) - { - return true; - } - - if (isHttp10) - { - return false; - } - // Anything unrecognised (e.g. a malformed version we still parsed) — - // be conservative and close. - return false; - } - - /// - /// True when the response has a well-defined body length (Content-Length - /// or chunked Transfer-Encoding, or a status that mandates an empty body). - /// Connection-close framing (no length, non-empty body) is not poolable - /// because the parser had to read to EOF to know where the body ended. - /// - public static bool HasDefiniteBodyFraming(HttpResponse response) - { - ArgumentNullException.ThrowIfNull(response); - - if (response.StatusCode is 204 or 304) - { - return true; - } - - var te = response.Headers.GetFirst("Transfer-Encoding"); - if (te is not null && ContainsToken(te, "chunked")) - { - return true; - } - - return response.Headers.GetFirst("Content-Length") is not null; - } - - private readonly record struct Headline(string HttpVersion, int StatusCode, string ReasonPhrase); -} diff --git a/src/Starling.Net/Http/H2/AsyncSignal.cs b/src/Starling.Net/Http/H2/AsyncSignal.cs deleted file mode 100644 index 182f273f..00000000 --- a/src/Starling.Net/Http/H2/AsyncSignal.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Starling.Net.Http.H2; - -/// -/// Edge-triggered async notification: waiters capture -/// before re-checking their condition, so a that races the -/// check is never lost. Used to wake stream senders when flow-control windows -/// grow or a concurrency slot frees up. -/// -/// -/// Correct usage: -/// -/// while (true) -/// { -/// var wait = signal.WaitAsync(); -/// if (ConditionMet()) break; -/// await wait; -/// } -/// -/// -internal sealed class AsyncSignal -{ - private readonly object _gate = new(); - private TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task WaitAsync() - { - lock (_gate) - { - return _tcs.Task; - } - } - - /// Wake all current waiters and arm a fresh signal for future waits. - public void Pulse() - { - lock (_gate) - { - _tcs.TrySetResult(); - _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - } -} diff --git a/src/Starling.Net/Http/H2/H2Connection.cs b/src/Starling.Net/Http/H2/H2Connection.cs deleted file mode 100644 index da901cb0..00000000 --- a/src/Starling.Net/Http/H2/H2Connection.cs +++ /dev/null @@ -1,972 +0,0 @@ -using System.Globalization; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Starling.Common; -using Starling.Common.Diagnostics; -using Starling.Net.Http.Decoding; -using Starling.Net.Http.H2.Hpack; -using StarlingUrl = global::Starling.Url.Url; - -namespace Starling.Net.Http.H2; - -internal static partial class H2ConnectionLog -{ - [LoggerMessage(Level = LogLevel.Warning, Message = "h2 connection error {ErrorCode}: {ErrorMessage}")] - public static partial void ConnectionError(ILogger logger, string errorCode, string errorMessage); - - [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during reader-loop teardown")] - public static partial void TransportDisposeFailed(ILogger logger, Exception ex); - - [LoggerMessage(Level = LogLevel.Debug, Message = "best-effort control frame failed on dying connection")] - public static partial void SafeWriteFailed(ILogger logger, Exception ex); - - [LoggerMessage(Level = LogLevel.Debug, Message = "reader task threw during DisposeAsync cleanup")] - public static partial void ReaderTaskCleanupFailed(ILogger logger, Exception ex); -} - -/// -/// A single HTTP/2 connection multiplexing many request/response streams over -/// one TLS transport (RFC 9113). One reader loop demultiplexes inbound frames -/// to per-stream state; outbound frames are serialized by the -/// . Owns the underlying -/// and tears it down when the connection closes. -/// -internal sealed class H2Connection : IAsyncDisposable -{ - // Our advertised receive settings. A generous stream/connection receive - // window lets servers send a sizeable first burst before our first - // WINDOW_UPDATE; we then replenish per DATA frame to keep windows topped up. - private const int OurInitialWindowSize = 8 * 1024 * 1024; - private const int OurMaxFrameSize = H2Protocol.DefaultMaxFrameSize; - - // Request header defaults, matching H1RequestWriter so both paths look - // identical to a server. - private const string DefaultUserAgent = "Starling/0.1 (https://github.com/anthropic-starling)"; - private const string DefaultAccept = - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; - private const string DefaultAcceptEncoding = "gzip, br, deflate"; - - private readonly IHttpTransport _transport; - private readonly H2FrameReader _reader; - private readonly H2FrameWriter _writer; - private readonly HpackEncoder _encoder = new(); - private readonly HpackDecoder _decoder = new(H2Protocol.DefaultHeaderTableSize); - private readonly ILogger _log; - private readonly Action? _onClosed; - - private readonly object _lock = new(); - private readonly Dictionary _streams = []; - private readonly AsyncSignal _windowSignal = new(); - private readonly SemaphoreSlim _openLock = new(1, 1); - - // Cancelled when the connection is torn down. Drives the reader loop and the - // best-effort control-frame writes that aren't bound to a caller's request, - // so they stop promptly instead of running against a dying transport. The - // token is captured up front so it stays usable after the source is disposed. - private readonly CancellationTokenSource _lifetime = new(); - private readonly CancellationToken _lifetimeToken; - - // Peer settings governing what we send. - private int _peerInitialWindowSize = H2Protocol.DefaultInitialWindowSize; - private int _peerMaxFrameSize = H2Protocol.DefaultMaxFrameSize; - private long _peerMaxConcurrentStreams = long.MaxValue; - private int _connSendWindow = H2Protocol.DefaultInitialWindowSize; - - private int _nextStreamId = 1; - private int _activeStreams; - private bool _goAwayReceived; - private bool _closed; - private int _transportDisposed; - - // Header-block assembly across HEADERS + CONTINUATION frames. - private readonly MemoryStream _headerFragments = new(); - private int _headerStreamId; // 0 == not currently assembling - private bool _headerEndStream; - - private Task _readerTask = Task.CompletedTask; - - public OriginKey Origin { get; } - - /// The verified leaf certificate of the underlying TLS transport, if any. - public Tls.CertificateSummary? PeerCertificate => _transport.PeerCertificate; - - private H2Connection(IHttpTransport transport, OriginKey origin, ILogger? log, Action? onClosed) - { - _transport = transport; - Origin = origin; - _log = log ?? NullLogger.Instance; - _onClosed = onClosed; - _lifetimeToken = _lifetime.Token; - _reader = new H2FrameReader(transport.Stream, OurMaxFrameSize); - _writer = new H2FrameWriter(transport.Stream); - } - - /// - /// Perform the connection preface, exchange SETTINGS, raise the connection - /// receive window, and start the reader loop. The returned connection is - /// ready to accept calls immediately (RFC 9113 §3.4 - /// permits sending requests right after our preface, before the server's - /// SETTINGS arrive). - /// - public static async Task StartAsync( - IHttpTransport transport, OriginKey origin, ILogger? log = null, - Action? onClosed = null, CancellationToken ct = default) - { - var conn = new H2Connection(transport, origin, log, onClosed); - await conn._writer.WritePrefaceAndSettingsAsync( - [ - (H2SettingId.EnablePush, 0), - (H2SettingId.InitialWindowSize, OurInitialWindowSize), - (H2SettingId.MaxFrameSize, OurMaxFrameSize), - (H2SettingId.HeaderTableSize, H2Protocol.DefaultHeaderTableSize), - ], ct).ConfigureAwait(false); - - // Raise the connection-level receive window from its 65535 default. - await conn._writer.WriteWindowUpdateAsync(0, OurInitialWindowSize - H2Protocol.DefaultInitialWindowSize, ct) - .ConfigureAwait(false); - - // The CancellationToken.None here only governs whether Task.Run starts the - // delegate; the loop's own cancellation rides on the connection lifetime token. - conn._readerTask = Task.Run(() => conn.ReaderLoopAsync(conn._lifetimeToken), CancellationToken.None); - StarlingTelemetry.Counter("net.h2.connections_opened", 1); - return conn; - } - - /// True while new streams can still be opened on this connection. - public bool IsUsable - { - get - { - lock (_lock) - { - return !_closed && !_goAwayReceived && _nextStreamId > 0 && _nextStreamId < int.MaxValue; - } - } - } - - /// - /// Open a stream, send the request, and await the assembled response. - /// Returns a retryable when the - /// connection went away before this request could be processed, so the - /// caller can re-dial. - /// - public async Task> SendAsync( - HttpRequest request, StarlingUrl url, CancellationToken ct) - { - H2Stream stream; - var fields = BuildRequestFields(request, url); - var block = _encoder.Encode(fields); - var hasBody = !request.Body.IsEmpty; - - await _openLock.WaitAsync(ct).ConfigureAwait(false); - try - { - // Reserve a concurrency slot and allocate a monotonically increasing - // stream id. Holding _openLock across the HEADERS write guarantees - // ids reach the wire in increasing order (RFC 9113 §5.1.1). - while (true) - { - var wait = _windowSignal.WaitAsync(); - lock (_lock) - { - if (_closed || _goAwayReceived) - { - return Result.Err(NetworkError.TransportFailure); - } - - if (_nextStreamId <= 0 || _nextStreamId >= int.MaxValue) - { - return Result.Err(NetworkError.TransportFailure); - } - - if (_activeStreams < _peerMaxConcurrentStreams) - { - stream = new H2Stream(_nextStreamId) { SendWindow = _peerInitialWindowSize }; - _streams[_nextStreamId] = stream; - _nextStreamId += 2; - _activeStreams++; - break; - } - } - await wait.ConfigureAwait(false); - } - - StarlingTelemetry.Counter("net.h2.requests", 1); - await _writer.WriteHeadersAsync(stream.Id, block, endStream: !hasBody, _peerMaxFrameSize, ct) - .ConfigureAwait(false); - } - finally - { - _openLock.Release(); - } - - if (hasBody) - { - await SendBodyAsync(stream, request.Body, ct).ConfigureAwait(false); - } - - try - { - return await stream.Completion.Task.WaitAsync(ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // The caller's token fired, so cancel the stream at the peer and drop - // our local state. We use the connection lifetime token (not the - // already-cancelled caller token) so the RST_STREAM still goes out - // while the connection is alive. - await SafeWriteAsync(() => _writer.WriteRstStreamAsync(stream.Id, H2ErrorCode.Cancel, _lifetimeToken)) - .ConfigureAwait(false); - RemoveStream(stream.Id); - throw; - } - } - - private async Task SendBodyAsync(H2Stream stream, ReadOnlyMemory body, CancellationToken ct) - { - var remaining = body; - while (!remaining.IsEmpty) - { - int budget; - while (true) - { - var wait = _windowSignal.WaitAsync(); - lock (_lock) - { - if (_closed) - { - return; // stream will be failed by the reader-loop teardown - } - - budget = Math.Min(Math.Min(stream.SendWindow, _connSendWindow), _peerMaxFrameSize); - budget = Math.Min(budget, remaining.Length); - if (budget > 0) - { - stream.SendWindow -= budget; - _connSendWindow -= budget; - break; - } - } - await wait.ConfigureAwait(false); - } - - var chunk = remaining[..budget]; - remaining = remaining[budget..]; - await _writer.WriteDataAsync(stream.Id, chunk, endStream: remaining.IsEmpty, ct) - .ConfigureAwait(false); - } - } - - private List<(string, string)> BuildRequestFields(HttpRequest request, StarlingUrl url) - { - var scheme = url.IsHttps ? "https" : "http"; - var path = H1.H1RequestWriter.BuildRequestTarget(url); - var authority = H1.H1RequestWriter.BuildHostHeader(url); - - var fields = new List<(string, string)>(request.Headers.Count + 8) - { - (":method", request.Method), - (":scheme", scheme), - (":authority", authority), - (":path", path), - }; - - var hasUserAgent = false; - var hasAccept = false; - var hasAcceptEncoding = false; - var hasContentLength = false; - - foreach (var kv in request.Headers) - { - var lower = kv.Key.ToLowerInvariant(); - // RFC 9113 §8.2.2: connection-specific header fields are forbidden; - // the authority replaces Host. - if (lower is "connection" or "keep-alive" or "proxy-connection" - or "transfer-encoding" or "upgrade" or "host") - { - continue; - } - - if (lower == "te" && !string.Equals(kv.Value, "trailers", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (lower == "user-agent") - { - hasUserAgent = true; - } - else if (lower == "accept") - { - hasAccept = true; - } - else if (lower == "accept-encoding") - { - hasAcceptEncoding = true; - } - else if (lower == "content-length") - { - hasContentLength = true; - } - - fields.Add((lower, kv.Value)); - } - - if (!hasUserAgent) - { - fields.Add(("user-agent", DefaultUserAgent)); - } - - if (!hasAccept) - { - fields.Add(("accept", DefaultAccept)); - } - - if (!hasAcceptEncoding) - { - fields.Add(("accept-encoding", DefaultAcceptEncoding)); - } - // Send content-length for body-bearing methods even with an empty body. - // END_STREAM already signals "no DATA", but some origins/WAFs reject an - // empty POST that lacks content-length (411). Browsers always send "0". - if (!hasContentLength && - (!request.Body.IsEmpty || H1.H1RequestWriter.MethodCarriesBody(request.Method))) - { - fields.Add(("content-length", - request.Body.Length.ToString(System.Globalization.CultureInfo.InvariantCulture))); - } - - return fields; - } - - // ---- Reader loop ------------------------------------------------------- - - private async Task ReaderLoopAsync(CancellationToken ct) - { - try - { - while (true) - { - RawFrame? maybe; - try - { - maybe = await _reader.ReadFrameAsync(ct).ConfigureAwait(false); - } - catch (EndOfStreamException) - { - break; // truncated frame == peer closed - } - - if (maybe is not { } frame) - { - break; // clean EOF - } - - await HandleFrameAsync(frame, ct).ConfigureAwait(false); - } - - CloseAll(NetworkError.TransportFailure); - } - catch (OperationCanceledException) - { - // The lifetime token fired — the connection is being torn down. - CloseAll(NetworkError.TransportFailure); - } - catch (H2ConnectionException ex) - { - await SafeWriteAsync(() => _writer.WriteGoAwayAsync(0, ex.Code, ct)) - .ConfigureAwait(false); - H2ConnectionLog.ConnectionError(_log, ex.Code.ToString(), ex.Message); - CloseAll(NetworkError.ProtocolError); - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException) - { - CloseAll(NetworkError.TransportFailure); - } - finally - { - // The loop is the sole reader; once it stops the socket is done. - // Disposing here prevents a leak when the server closes first. - await SafeDisposeTransportAsync().ConfigureAwait(false); - } - } - - private async Task HandleFrameAsync(RawFrame frame, CancellationToken ct) - { - // While assembling a header block only CONTINUATION frames for the same - // stream are legal (RFC 9113 §6.10). - if (_headerStreamId != 0 - && (frame.Type != H2FrameType.Continuation || frame.StreamId != _headerStreamId)) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "expected CONTINUATION"); - } - - switch (frame.Type) - { - case H2FrameType.Headers: HandleHeaders(frame); break; - case H2FrameType.Continuation: HandleContinuation(frame); break; - case H2FrameType.Data: await HandleDataAsync(frame, ct).ConfigureAwait(false); break; - case H2FrameType.Settings: await HandleSettingsAsync(frame, ct).ConfigureAwait(false); break; - case H2FrameType.WindowUpdate: HandleWindowUpdate(frame); break; - case H2FrameType.Ping: await HandlePingAsync(frame, ct).ConfigureAwait(false); break; - case H2FrameType.GoAway: HandleGoAway(frame); break; - case H2FrameType.RstStream: HandleRstStream(frame); break; - case H2FrameType.Priority: break; // ignored - case H2FrameType.PushPromise: - // We advertised ENABLE_PUSH=0, so a push is a protocol error. - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "unexpected PUSH_PROMISE"); - default: break; // unknown frame types are ignored (§4.1) - } - } - - private void HandleHeaders(RawFrame frame) - { - if (frame.StreamId == 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS on stream 0"); - } - - var payload = frame.Payload.AsSpan(); - var content = StripHeadersPadding(payload, frame.Flags); - - _headerFragments.SetLength(0); - _headerFragments.Write(content); - _headerStreamId = frame.StreamId; - _headerEndStream = frame.HasFlag(H2Flags.EndStream); - - if (frame.HasFlag(H2Flags.EndHeaders)) - { - CompleteHeaderBlock(); - } - } - - private void HandleContinuation(RawFrame frame) - { - _headerFragments.Write(frame.Payload); - if (frame.HasFlag(H2Flags.EndHeaders)) - { - CompleteHeaderBlock(); - } - } - - private void CompleteHeaderBlock() - { - var streamId = _headerStreamId; - var endStream = _headerEndStream; - var block = _headerFragments.ToArray(); - _headerStreamId = 0; - _headerFragments.SetLength(0); - - // Always decode to keep HPACK state in sync, even if the stream is gone. - if (!_decoder.TryDecode(block, out var fields)) - { - throw new H2ConnectionException(H2ErrorCode.CompressionError, "HPACK decode failed"); - } - - H2Stream? stream; - lock (_lock) - { - _streams.TryGetValue(streamId, out stream); - } - - if (stream is null) - { - return; // unknown/reset stream — fields discarded - } - - if (stream.ResponseHeaders is null) - { - var status = ReadStatus(fields); - if (status is null) - { - FailStream(stream, NetworkError.ProtocolError, H2ErrorCode.ProtocolError); - return; - } - if (status is >= 100 and < 200) - { - return; // interim (1xx) response — keep waiting for the final one - } - - stream.ResponseHeaders = fields; - } - // else: trailers — decoded for state, otherwise ignored (v1). - - if (endStream) - { - FinishStream(stream); - } - } - - private async Task HandleDataAsync(RawFrame frame, CancellationToken ct) - { - if (frame.StreamId == 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "DATA on stream 0"); - } - - var flowLength = frame.Payload.Length; // padding counts toward flow control - var data = StripDataPadding(frame.Payload.AsSpan(), frame.Flags); - - H2Stream? stream; - lock (_lock) - { - _streams.TryGetValue(frame.StreamId, out stream); - } - - if (stream is not null) - { - stream.Body.Write(data); - if (frame.HasFlag(H2Flags.EndStream)) - { - FinishStream(stream); - } - } - - // Replenish receive windows so the peer can keep sending. - if (flowLength > 0) - { - await _writer.WriteWindowUpdateAsync(0, flowLength, ct).ConfigureAwait(false); - if (stream is not null && !frame.HasFlag(H2Flags.EndStream)) - { - await _writer.WriteWindowUpdateAsync(frame.StreamId, flowLength, ct) - .ConfigureAwait(false); - } - } - } - - private async Task HandleSettingsAsync(RawFrame frame, CancellationToken ct) - { - if (frame.StreamId != 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "SETTINGS on non-zero stream"); - } - - if (frame.HasFlag(H2Flags.Ack)) - { - if (frame.Payload.Length != 0) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "SETTINGS ACK with payload"); - } - - return; - } - if (frame.Payload.Length % 6 != 0) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "SETTINGS length not a multiple of 6"); - } - - var p = frame.Payload; - for (var i = 0; i < p.Length; i += 6) - { - var id = (H2SettingId)((p[i] << 8) | p[i + 1]); - var value = ((uint)p[i + 2] << 24) | ((uint)p[i + 3] << 16) | ((uint)p[i + 4] << 8) | p[i + 5]; - ApplyPeerSetting(id, value); - } - - _windowSignal.Pulse(); - await _writer.WriteSettingsAckAsync(ct).ConfigureAwait(false); - } - - private void ApplyPeerSetting(H2SettingId id, uint value) - { - switch (id) - { - case H2SettingId.InitialWindowSize: - if (value > int.MaxValue) - { - throw new H2ConnectionException(H2ErrorCode.FlowControlError, "INITIAL_WINDOW_SIZE too large"); - } - - lock (_lock) - { - var delta = (int)value - _peerInitialWindowSize; - _peerInitialWindowSize = (int)value; - foreach (var s in _streams.Values) - { - s.SendWindow += delta; // RFC 9113 §6.9.2 - } - } - break; - case H2SettingId.MaxFrameSize: - if (value is < H2Protocol.DefaultMaxFrameSize or > H2Protocol.MaxAllowedFrameSize) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "invalid MAX_FRAME_SIZE"); - } - - lock (_lock) - { - _peerMaxFrameSize = (int)value; - } - - break; - case H2SettingId.MaxConcurrentStreams: - lock (_lock) - { - _peerMaxConcurrentStreams = value; - } - - break; - case H2SettingId.EnablePush: - if (value > 1) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "invalid ENABLE_PUSH"); - } - - break; - case H2SettingId.HeaderTableSize: - case H2SettingId.MaxHeaderListSize: - default: - break; // our encoder uses no dynamic table; no list-size cap enforced - } - } - - private void HandleWindowUpdate(RawFrame frame) - { - if (frame.Payload.Length != 4) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "WINDOW_UPDATE length != 4"); - } - - var increment = (int)(((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16 - | (uint)frame.Payload[2] << 8 | frame.Payload[3]) & 0x7fff_ffff); - - if (frame.StreamId == 0) - { - if (increment == 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "0 connection WINDOW_UPDATE"); - } - - lock (_lock) - { - var updated = (long)_connSendWindow + increment; - if (updated > H2Protocol.MaxWindowSize) - { - throw new H2ConnectionException(H2ErrorCode.FlowControlError, "connection window overflow"); - } - - _connSendWindow = (int)updated; - } - _windowSignal.Pulse(); - return; - } - - H2Stream? stream; - lock (_lock) - { - _streams.TryGetValue(frame.StreamId, out stream); - if (stream is not null && increment > 0) - { - stream.SendWindow = (int)Math.Min((long)stream.SendWindow + increment, H2Protocol.MaxWindowSize); - } - } - if (increment > 0) - { - _windowSignal.Pulse(); - } - } - - private async Task HandlePingAsync(RawFrame frame, CancellationToken ct) - { - if (frame.StreamId != 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "PING on non-zero stream"); - } - - if (frame.Payload.Length != 8) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "PING length != 8"); - } - - if (!frame.HasFlag(H2Flags.Ack)) - { - await _writer.WritePingAckAsync(frame.Payload, ct).ConfigureAwait(false); - } - } - - private void HandleGoAway(RawFrame frame) - { - if (frame.Payload.Length < 8) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "GOAWAY too short"); - } - - var lastStreamId = (int)(((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16 - | (uint)frame.Payload[2] << 8 | frame.Payload[3]) & 0x7fff_ffff); - - List refused; - lock (_lock) - { - _goAwayReceived = true; - // Streams above lastStreamId were never processed — safe to retry. - refused = _streams.Values.Where(s => s.Id > lastStreamId).ToList(); - } - foreach (var s in refused) - { - FailStream(s, NetworkError.TransportFailure, rstCode: null); - } - - _windowSignal.Pulse(); - } - - private void HandleRstStream(RawFrame frame) - { - if (frame.StreamId == 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "RST_STREAM on stream 0"); - } - - if (frame.Payload.Length != 4) - { - throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "RST_STREAM length != 4"); - } - - var code = (H2ErrorCode)((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16 - | (uint)frame.Payload[2] << 8 | frame.Payload[3]); - - H2Stream? stream; - lock (_lock) - { - _streams.TryGetValue(frame.StreamId, out stream); - } - - if (stream is not null) - { - var err = code == H2ErrorCode.RefusedStream - ? NetworkError.TransportFailure // never processed — retryable - : NetworkError.ProtocolError; - FailStream(stream, err, rstCode: null); - } - } - - // ---- Stream completion ------------------------------------------------- - - private void FinishStream(H2Stream stream) - { - lock (_lock) - { - if (stream.Finished) - { - return; - } - - stream.Finished = true; - _streams.Remove(stream.Id); - _activeStreams--; - } - _windowSignal.Pulse(); - - var result = BuildResponse(stream); - stream.Completion.TrySetResult(result); - } - - private void FailStream(H2Stream stream, NetworkError error, H2ErrorCode? rstCode) - { - bool first; - lock (_lock) - { - first = !stream.Finished; - if (first) - { - stream.Finished = true; - _streams.Remove(stream.Id); - _activeStreams--; - } - } - if (!first) - { - return; - } - - _windowSignal.Pulse(); - - if (rstCode is { } rc) - { - _ = SafeWriteAsync(() => _writer.WriteRstStreamAsync(stream.Id, rc, _lifetimeToken)); - } - - stream.Completion.TrySetResult(Result.Err(error)); - } - - private static Result BuildResponse(H2Stream stream) - { - var fields = stream.ResponseHeaders!; - var status = ReadStatus(fields)!.Value; - - var headers = new HttpHeaders(); - foreach (var f in fields) - { - if (f.Name.Length == 0 || f.Name[0] == ':') - { - continue; // skip pseudo-headers - } - - try { headers.Add(f.Name, f.Value); } - catch (ArgumentException) { return Result.Err(NetworkError.ProtocolError); } - } - - var raw = stream.Body.ToArray(); - byte[] decoded; - try - { - var encodings = BodyDecoder.ParseEncodings(headers.GetFirst("Content-Encoding")); - decoded = encodings.Count == 0 ? raw : BodyDecoder.Decode(raw, encodings); - } - catch (Exception ex) when (ex is NotSupportedException or InvalidDataException) - { - return Result.Err(NetworkError.ProtocolError); - } - - return Result.Ok( - new HttpResponse("HTTP/2", status, string.Empty, headers, decoded)); - } - - private static int? ReadStatus(List fields) - { - foreach (var f in fields) - { - if (!string.Equals(f.Name, ":status", StringComparison.Ordinal)) - { - continue; - } - - return int.TryParse(f.Value, NumberStyles.None, CultureInfo.InvariantCulture, out var code) - && code is >= 100 and <= 599 - ? code - : null; - } - return null; - } - - // ---- Teardown ---------------------------------------------------------- - - private void CloseAll(NetworkError error) - { - List pending; - lock (_lock) - { - if (_closed) - { - return; - } - - _closed = true; - pending = [.. _streams.Values]; - _streams.Clear(); - _activeStreams = 0; - } - // Stop the reader loop and any in-flight control-frame writes. - _lifetime.Cancel(); - _windowSignal.Pulse(); - foreach (var s in pending) - { - if (!s.Finished) - { - s.Finished = true; - s.Completion.TrySetResult(Result.Err(error)); - } - } - _onClosed?.Invoke(this); - } - - private void RemoveStream(int streamId) - { - lock (_lock) - { - if (_streams.Remove(streamId)) - { - _activeStreams--; - } - } - _windowSignal.Pulse(); - } - - private async Task SafeDisposeTransportAsync() - { - if (Interlocked.Exchange(ref _transportDisposed, 1) != 0) - { - return; - } - - try { await _transport.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { H2ConnectionLog.TransportDisposeFailed(_log, ex); /* a half-broken socket may throw on shutdown */ } - } - - private async Task SafeWriteAsync(Func write) - { - try { await write().ConfigureAwait(false); } - catch (Exception ex) { H2ConnectionLog.SafeWriteFailed(_log, ex); /* best-effort control frame on a dying connection */ } - } - - private static Span StripHeadersPadding(Span payload, H2Flags flags) - { - var offset = 0; - var padLength = 0; - if ((flags & H2Flags.Padded) != 0) - { - if (payload.Length < 1) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "padded HEADERS too short"); - } - - padLength = payload[0]; - offset = 1; - } - if ((flags & H2Flags.Priority) != 0) - { - if (payload.Length < offset + 5) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS priority too short"); - } - - offset += 5; - } - var contentLength = payload.Length - offset - padLength; - if (contentLength < 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS padding exceeds frame"); - } - - return payload.Slice(offset, contentLength); - } - - private static Span StripDataPadding(Span payload, H2Flags flags) - { - if ((flags & H2Flags.Padded) == 0) - { - return payload; - } - - if (payload.Length < 1) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "padded DATA too short"); - } - - var padLength = payload[0]; - var contentLength = payload.Length - 1 - padLength; - if (contentLength < 0) - { - throw new H2ConnectionException(H2ErrorCode.ProtocolError, "DATA padding exceeds frame"); - } - - return payload.Slice(1, contentLength); - } - - public async ValueTask DisposeAsync() - { - // Graceful goodbye while the connection is still alive; if the reader loop - // already tore down (lifetime cancelled), this write is skipped. - await SafeWriteAsync(() => _writer.WriteGoAwayAsync(0, H2ErrorCode.NoError, _lifetimeToken)) - .ConfigureAwait(false); - CloseAll(NetworkError.TransportFailure); - // Dispose the transport first to unblock the reader loop's pending - // ReadAsync, then wait for the loop to exit before tearing down the - // writer it might still touch. - await SafeDisposeTransportAsync().ConfigureAwait(false); - try { await _readerTask.ConfigureAwait(false); } - catch (Exception ex) { H2ConnectionLog.ReaderTaskCleanupFailed(_log, ex); /* loop teardown */ } - _writer.Dispose(); - _openLock.Dispose(); - _lifetime.Dispose(); - } -} diff --git a/src/Starling.Net/Http/H2/H2ConnectionManager.cs b/src/Starling.Net/Http/H2/H2ConnectionManager.cs deleted file mode 100644 index 9718d564..00000000 --- a/src/Starling.Net/Http/H2/H2ConnectionManager.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Starling.Net.Http.H2; - -internal static partial class H2ConnectionManagerLog -{ - [LoggerMessage(Level = LogLevel.Debug, Message = "H2 connection dispose threw during teardown")] - public static partial void ConnectionDisposeFailed(ILogger logger, Exception ex); -} - -/// -/// Holds at most one live per origin. Unlike the -/// HTTP/1.1 (a queue of idle single-use sockets), -/// an HTTP/2 connection is long-lived and multiplexed, so the "pool" is just a -/// per-origin map of shared connections. -/// -internal sealed class H2ConnectionManager : IAsyncDisposable -{ - private readonly object _gate = new(); - private readonly Dictionary _byOrigin = []; - private readonly ILogger _log; - private bool _disposed; - - public H2ConnectionManager(ILogger? log = null) - { - _log = log ?? NullLogger.Instance; - } - - /// Return the live, usable connection for an origin, or null. - public H2Connection? TryGet(OriginKey origin) - { - lock (_gate) - { - if (_disposed) - { - return null; - } - - if (!_byOrigin.TryGetValue(origin, out var conn)) - { - return null; - } - - if (conn.IsUsable) - { - return conn; - } - // Stale (GOAWAY/closed) — drop the reference; it disposes itself. - _byOrigin.Remove(origin); - return null; - } - } - - /// - /// Register as the connection for its origin, - /// unless another usable connection won a concurrent race — in which case - /// the winner is returned and the caller must dispose the candidate. - /// - public H2Connection Adopt(OriginKey origin, H2Connection candidate) - { - lock (_gate) - { - if (!_disposed - && _byOrigin.TryGetValue(origin, out var existing) - && existing.IsUsable) - { - return existing; - } - _byOrigin[origin] = candidate; - return candidate; - } - } - - /// Drop a connection (called from its close callback) if still mapped. - public void Remove(OriginKey origin, H2Connection conn) - { - lock (_gate) - { - if (_byOrigin.TryGetValue(origin, out var current) && ReferenceEquals(current, conn)) - { - _byOrigin.Remove(origin); - } - } - } - - public async ValueTask DisposeAsync() - { - List all; - lock (_gate) - { - _disposed = true; - all = [.. _byOrigin.Values]; - _byOrigin.Clear(); - } - foreach (var c in all) - { - try { await c.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { H2ConnectionManagerLog.ConnectionDisposeFailed(_log, ex); /* teardown of a dying connection */ } - } - } -} diff --git a/src/Starling.Net/Http/H2/H2Exception.cs b/src/Starling.Net/Http/H2/H2Exception.cs deleted file mode 100644 index f6765630..00000000 --- a/src/Starling.Net/Http/H2/H2Exception.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Starling.Net.Http.H2; - -// RCS1194: this is a purpose-built internal signal that always carries an -// H2ErrorCode, so the standard parameterless / inner-exception constructors -// don't apply. -#pragma warning disable RCS1194 -/// -/// A connection-level HTTP/2 error (RFC 9113 §5.4.1): fatal to the whole -/// connection. Carries the error code we will report in a GOAWAY frame. -/// -internal sealed class H2ConnectionException(H2ErrorCode code, string message) : Exception(message) -{ - public H2ErrorCode Code { get; } = code; -} -#pragma warning restore RCS1194 diff --git a/src/Starling.Net/Http/H2/H2FrameReader.cs b/src/Starling.Net/Http/H2/H2FrameReader.cs deleted file mode 100644 index c9e9c440..00000000 --- a/src/Starling.Net/Http/H2/H2FrameReader.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Starling.Net.Http.H2; - -/// One frame read off the wire: header fields plus the raw payload. -internal readonly struct RawFrame(H2FrameType type, H2Flags flags, int streamId, byte[] payload) -{ - public H2FrameType Type { get; } = type; - public H2Flags Flags { get; } = flags; - public int StreamId { get; } = streamId; - public byte[] Payload { get; } = payload; - - public bool HasFlag(H2Flags flag) => (Flags & flag) == flag; -} - -/// -/// Reads HTTP/2 frames (RFC 9113 §4.1) from a byte stream. Each call returns -/// the next whole frame or null on a clean end-of-stream. A frame whose length -/// exceeds the negotiated maximum is a connection-level FRAME_SIZE_ERROR. -/// -internal sealed class H2FrameReader(Stream stream, int maxFrameSize) -{ - private readonly byte[] _header = new byte[H2Protocol.FrameHeaderLength]; - - public async Task ReadFrameAsync(CancellationToken ct) - { - if (!await TryReadExactAsync(_header, ct).ConfigureAwait(false)) - { - return null; // clean EOF between frames - } - - var length = (_header[0] << 16) | (_header[1] << 8) | _header[2]; - var type = (H2FrameType)_header[3]; - var flags = (H2Flags)_header[4]; - var streamId = - ((_header[5] & 0x7f) << 24) | (_header[6] << 16) | (_header[7] << 8) | _header[8]; - - if (length > maxFrameSize) - { - throw new H2ConnectionException( - H2ErrorCode.FrameSizeError, $"frame length {length} exceeds max {maxFrameSize}"); - } - - var payload = length == 0 ? [] : new byte[length]; - if (length > 0 && !await TryReadExactAsync(payload, ct).ConfigureAwait(false)) - { - return null; // truncated payload — treat as connection closed - } - - return new RawFrame(type, flags, streamId, payload); - } - - private async Task TryReadExactAsync(Memory buffer, CancellationToken ct) - { - var read = 0; - while (read < buffer.Length) - { - var n = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false); - if (n == 0) - { - return read == 0 ? false : throw new EndOfStreamException("truncated HTTP/2 frame"); - } - - read += n; - } - return true; - } -} diff --git a/src/Starling.Net/Http/H2/H2FrameWriter.cs b/src/Starling.Net/Http/H2/H2FrameWriter.cs deleted file mode 100644 index 655f6da9..00000000 --- a/src/Starling.Net/Http/H2/H2FrameWriter.cs +++ /dev/null @@ -1,156 +0,0 @@ -namespace Starling.Net.Http.H2; - -/// -/// Serializes and writes HTTP/2 frames to the connection's byte stream. All -/// writes are serialized through a single semaphore so frames never interleave -/// at the octet level, and a HEADERS block plus its CONTINUATION frames are -/// emitted contiguously (RFC 9113 §6.10) by holding the lock across the whole -/// sequence. -/// -internal sealed class H2FrameWriter(Stream stream) : IDisposable -{ - private readonly SemaphoreSlim _writeLock = new(1, 1); - - /// Send the client preface immediately followed by our SETTINGS frame. - public async Task WritePrefaceAndSettingsAsync( - IReadOnlyList<(H2SettingId Id, uint Value)> settings, CancellationToken ct) - { - await _writeLock.WaitAsync(ct).ConfigureAwait(false); - try - { - await stream.WriteAsync(H2Protocol.ClientPreface.ToArray(), ct).ConfigureAwait(false); - await WriteSettingsLockedAsync(settings, ct).ConfigureAwait(false); - await stream.FlushAsync(ct).ConfigureAwait(false); - } - finally { _writeLock.Release(); } - } - - public Task WriteSettingsAckAsync(CancellationToken ct) => - WriteSimpleAsync(H2FrameType.Settings, H2Flags.Ack, 0, ReadOnlyMemory.Empty, ct); - - public Task WritePingAckAsync(byte[] opaqueData, CancellationToken ct) => - WriteSimpleAsync(H2FrameType.Ping, H2Flags.Ack, 0, opaqueData, ct); - - public Task WriteRstStreamAsync(int streamId, H2ErrorCode code, CancellationToken ct) - { - var payload = new byte[4]; - WriteUInt32(payload, (uint)code); - return WriteSimpleAsync(H2FrameType.RstStream, H2Flags.None, streamId, payload, ct); - } - - public Task WriteWindowUpdateAsync(int streamId, int increment, CancellationToken ct) - { - var payload = new byte[4]; - WriteUInt32(payload, (uint)increment); - return WriteSimpleAsync(H2FrameType.WindowUpdate, H2Flags.None, streamId, payload, ct); - } - - public Task WriteGoAwayAsync(int lastStreamId, H2ErrorCode code, CancellationToken ct) - { - var payload = new byte[8]; - WriteUInt32(payload.AsSpan(0), (uint)lastStreamId); - WriteUInt32(payload.AsSpan(4), (uint)code); - _ = WriteSimpleAsync(H2FrameType.GoAway, H2Flags.None, 0, payload, ct); - - // don't wait on the GOAWAY - return Task.CompletedTask; - } - - /// Write one DATA frame for . - public Task WriteDataAsync(int streamId, ReadOnlyMemory data, bool endStream, CancellationToken ct) => - WriteSimpleAsync( - H2FrameType.Data, endStream ? H2Flags.EndStream : H2Flags.None, streamId, data, ct); - - /// - /// Write a HEADERS frame, splitting into CONTINUATION frames when the - /// encoded block exceeds the peer's max frame size. END_STREAM (if set) - /// rides on the HEADERS frame; END_HEADERS rides on the final fragment. - /// - public async Task WriteHeadersAsync( - int streamId, ReadOnlyMemory block, bool endStream, int peerMaxFrameSize, CancellationToken ct) - { - await _writeLock.WaitAsync(ct).ConfigureAwait(false); - try - { - var first = Math.Min(block.Length, peerMaxFrameSize); - var isOnly = first == block.Length; - var flags = (endStream ? H2Flags.EndStream : H2Flags.None) - | (isOnly ? H2Flags.EndHeaders : H2Flags.None); - await WriteFrameLockedAsync(H2FrameType.Headers, flags, streamId, block[..first], ct) - .ConfigureAwait(false); - - var offset = first; - while (offset < block.Length) - { - var n = Math.Min(block.Length - offset, peerMaxFrameSize); - var last = offset + n == block.Length; - await WriteFrameLockedAsync( - H2FrameType.Continuation, - last ? H2Flags.EndHeaders : H2Flags.None, - streamId, - block.Slice(offset, n), - ct).ConfigureAwait(false); - offset += n; - } - - await stream.FlushAsync(ct).ConfigureAwait(false); - } - finally { _writeLock.Release(); } - } - - private async Task WriteSimpleAsync( - H2FrameType type, H2Flags flags, int streamId, ReadOnlyMemory payload, CancellationToken ct) - { - await _writeLock.WaitAsync(ct).ConfigureAwait(false); - try - { - await WriteFrameLockedAsync(type, flags, streamId, payload, ct).ConfigureAwait(false); - await stream.FlushAsync(ct).ConfigureAwait(false); - } - finally { _writeLock.Release(); } - } - - private async Task WriteSettingsLockedAsync( - IReadOnlyList<(H2SettingId Id, uint Value)> settings, CancellationToken ct) - { - var payload = new byte[settings.Count * 6]; - for (var i = 0; i < settings.Count; i++) - { - var o = i * 6; - payload[o] = (byte)((ushort)settings[i].Id >> 8); - payload[o + 1] = (byte)(ushort)settings[i].Id; - WriteUInt32(payload.AsSpan(o + 2), settings[i].Value); - } - await WriteFrameLockedAsync(H2FrameType.Settings, H2Flags.None, 0, payload, ct) - .ConfigureAwait(false); - } - - private async Task WriteFrameLockedAsync( - H2FrameType type, H2Flags flags, int streamId, ReadOnlyMemory payload, CancellationToken ct) - { - var header = new byte[H2Protocol.FrameHeaderLength]; - var len = payload.Length; - header[0] = (byte)(len >> 16); - header[1] = (byte)(len >> 8); - header[2] = (byte)len; - header[3] = (byte)type; - header[4] = (byte)flags; - WriteUInt32(header.AsSpan(5), (uint)streamId & 0x7fff_ffff); - - await stream.WriteAsync(header, ct).ConfigureAwait(false); - if (len > 0) - { - await stream.WriteAsync(payload, ct).ConfigureAwait(false); - } - } - - private static void WriteUInt32(Span dst, uint value) - { - dst[0] = (byte)(value >> 24); - dst[1] = (byte)(value >> 16); - dst[2] = (byte)(value >> 8); - dst[3] = (byte)value; - } - - public void Dispose() => _writeLock.Dispose(); -} diff --git a/src/Starling.Net/Http/H2/H2Protocol.cs b/src/Starling.Net/Http/H2/H2Protocol.cs deleted file mode 100644 index 19b4b8c0..00000000 --- a/src/Starling.Net/Http/H2/H2Protocol.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace Starling.Net.Http.H2; - -/// HTTP/2 frame types (RFC 9113 §6). -internal enum H2FrameType : byte -{ - Data = 0x0, - Headers = 0x1, - Priority = 0x2, - RstStream = 0x3, - Settings = 0x4, - PushPromise = 0x5, - Ping = 0x6, - GoAway = 0x7, - WindowUpdate = 0x8, - Continuation = 0x9, -} - -/// HTTP/2 frame flag bits (RFC 9113 §6). Meanings are per-frame-type. -[Flags] -internal enum H2Flags : byte -{ - None = 0, - Ack = 0x1, // SETTINGS, PING - EndStream = 0x1, // DATA, HEADERS - EndHeaders = 0x4, // HEADERS, CONTINUATION, PUSH_PROMISE - Padded = 0x8, // DATA, HEADERS, PUSH_PROMISE - Priority = 0x20, // HEADERS -} - -/// HTTP/2 error codes (RFC 9113 §7). -internal enum H2ErrorCode : uint -{ - NoError = 0x0, - ProtocolError = 0x1, - InternalError = 0x2, - FlowControlError = 0x3, - SettingsTimeout = 0x4, - StreamClosed = 0x5, - FrameSizeError = 0x6, - RefusedStream = 0x7, - Cancel = 0x8, - CompressionError = 0x9, - ConnectError = 0xa, - EnhanceYourCalm = 0xb, - InadequateSecurity = 0xc, - Http11Required = 0xd, -} - -/// SETTINGS parameter identifiers (RFC 9113 §6.5.2). -internal enum H2SettingId : ushort -{ - HeaderTableSize = 0x1, - EnablePush = 0x2, - MaxConcurrentStreams = 0x3, - InitialWindowSize = 0x4, - MaxFrameSize = 0x5, - MaxHeaderListSize = 0x6, -} - -/// Wire constants and protocol defaults (RFC 9113). -internal static class H2Protocol -{ - /// Client connection preface (RFC 9113 §3.4), 24 octets. - public static ReadOnlySpan ClientPreface => - "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8; - - /// Fixed frame-header length: 24-bit length + type + flags + 31-bit stream id. - public const int FrameHeaderLength = 9; - - /// Default and minimum SETTINGS_MAX_FRAME_SIZE (RFC 9113 §6.5.2), 2^14. - public const int DefaultMaxFrameSize = 16_384; - - /// Largest permitted SETTINGS_MAX_FRAME_SIZE, 2^24 - 1. - public const int MaxAllowedFrameSize = 16_777_215; - - /// Default SETTINGS_INITIAL_WINDOW_SIZE (RFC 9113 §6.9.2), 2^16 - 1. - public const int DefaultInitialWindowSize = 65_535; - - /// Largest legal flow-control window; exceeding it is a FLOW_CONTROL_ERROR. - public const int MaxWindowSize = int.MaxValue; // 2^31 - 1 - - /// Default SETTINGS_HEADER_TABLE_SIZE (RFC 7541 §4.2). - public const int DefaultHeaderTableSize = 4_096; -} diff --git a/src/Starling.Net/Http/H2/H2Stream.cs b/src/Starling.Net/Http/H2/H2Stream.cs deleted file mode 100644 index 5a64f341..00000000 --- a/src/Starling.Net/Http/H2/H2Stream.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Starling.Common; -using Starling.Net.Http.H2.Hpack; - -namespace Starling.Net.Http.H2; - -/// -/// State for a single client-initiated HTTP/2 stream: the response being -/// assembled from HEADERS/DATA frames, the send-side flow-control window for -/// any request body, and the completion source the caller awaits. -/// -/// -/// Mutable fields are written by the connection's single reader loop or under -/// the connection lock; the caller only reads . -/// -internal sealed class H2Stream(int id) -{ - public int Id { get; } = id; - - /// Completed once the full response is assembled, or on stream/connection failure. - public TaskCompletionSource> Completion { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Final (non-1xx) response headers, set when the response HEADERS block decodes. - public List? ResponseHeaders { get; set; } - - /// Accumulated, de-padded response body bytes (still content-encoded). - public MemoryStream Body { get; } = new(); - - /// Remaining peer flow-control credit for sending this stream's body. - public int SendWindow { get; set; } - - /// True once the result has been published, to guard double-completion. - public bool Finished { get; set; } -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs b/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs deleted file mode 100644 index 7df3aa62..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Text; - -namespace Starling.Net.Http.H2.Hpack; - -/// A single decoded header field. -internal readonly record struct HpackHeaderField(string Name, string Value, bool NeverIndexed); - -/// -/// HPACK decoder (RFC 7541 §3 / §6). Stateful: one decoder mirrors the peer -/// encoder's dynamic table across an entire connection, so header blocks must -/// be fed in the order they arrive on the wire. Header octets are interpreted -/// as Latin-1 so each octet maps to exactly one char — keeping the dynamic -/// table's size accounting (octet length) equal to string.Length. -/// -internal sealed class HpackDecoder -{ - private readonly HpackDynamicTable _dynamic; - private readonly int _maxAllowedTableSize; - - public HpackDecoder(int maxDynamicTableSize) - { - _maxAllowedTableSize = maxDynamicTableSize; - _dynamic = new HpackDynamicTable(maxDynamicTableSize); - } - - /// - /// Decode one complete header block into . Returns - /// false on any malformed input (RFC 7541 calls these COMPRESSION_ERROR): - /// truncation, a zero or out-of-range index, an invalid Huffman string, or - /// a dynamic-table size update above the negotiated maximum. - /// - public bool TryDecode(ReadOnlySpan block, out List fields) - { - fields = []; - var offset = 0; - - while (offset < block.Length) - { - var b = block[offset]; - - if ((b & 0x80) != 0) - { - // §6.1 Indexed Header Field. - if (!HpackInteger.TryDecode(block, ref offset, 7, out var index) || index == 0) - { - return false; - } - - if (!Resolve(index, out var name, out var value)) - { - return false; - } - - fields.Add(new HpackHeaderField(name, value, NeverIndexed: false)); - } - else if ((b & 0x40) != 0) - { - // §6.2.1 Literal Header Field with Incremental Indexing. - if (!ReadLiteral(block, ref offset, 6, out var name, out var value)) - { - return false; - } - - _dynamic.Add(name, value, name.Length, value.Length); - fields.Add(new HpackHeaderField(name, value, NeverIndexed: false)); - } - else if ((b & 0x20) != 0) - { - // §6.3 Dynamic Table Size Update. - if (!HpackInteger.TryDecode(block, ref offset, 5, out var newSize)) - { - return false; - } - - if (newSize > _maxAllowedTableSize) - { - return false; - } - - _dynamic.Resize(newSize); - } - else - { - // §6.2.2 (0x00) without indexing, §6.2.3 (0x10) never indexed. - var neverIndexed = (b & 0x10) != 0; - if (!ReadLiteral(block, ref offset, 4, out var name, out var value)) - { - return false; - } - - fields.Add(new HpackHeaderField(name, value, neverIndexed)); - } - } - - return true; - } - - /// Read a literal representation whose name is either an index or an inline string. - private bool ReadLiteral( - ReadOnlySpan block, ref int offset, int namePrefixBits, out string name, out string value) - { - name = string.Empty; - value = string.Empty; - - if (!HpackInteger.TryDecode(block, ref offset, namePrefixBits, out var nameIndex)) - { - return false; - } - - if (nameIndex == 0) - { - if (!TryReadString(block, ref offset, out name)) - { - return false; - } - } - else if (!Resolve(nameIndex, out name, out _)) - { - return false; - } - - return TryReadString(block, ref offset, out value); - } - - /// Resolve a combined (static + dynamic) index to a name/value pair. - private bool Resolve(int index, out string name, out string value) - { - if (index <= HpackStaticTable.Count) - { - return HpackStaticTable.TryGet(index, out name, out value); - } - - return _dynamic.TryGet(index - HpackStaticTable.Count, out name, out value); - } - - /// Read a length-prefixed (optionally Huffman-coded) string literal (§5.2). - private static bool TryReadString(ReadOnlySpan block, ref int offset, out string result) - { - result = string.Empty; - if (offset >= block.Length) - { - return false; - } - - var huffman = (block[offset] & 0x80) != 0; - if (!HpackInteger.TryDecode(block, ref offset, 7, out var length)) - { - return false; - } - - if (length < 0 || offset + length > block.Length) - { - return false; - } - - var raw = block.Slice(offset, length); - offset += length; - - if (huffman) - { - if (!HpackHuffman.TryDecode(raw, out var decoded)) - { - return false; - } - - result = Encoding.Latin1.GetString(decoded); - } - else - { - result = Encoding.Latin1.GetString(raw); - } - return true; - } -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs b/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs deleted file mode 100644 index 44374074..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace Starling.Net.Http.H2.Hpack; - -/// -/// HPACK dynamic table (RFC 7541 §2.3.2 / §4): a FIFO of recently seen header -/// fields that follows the static table in the index space. Newly inserted -/// entries get the lowest dynamic index (combined index 62); the table evicts -/// oldest-first to stay within its size bound. -/// -/// -/// One direction of HPACK state. A decoder owns one of these to mirror the -/// peer encoder's table; entry size accounting (name octets + value octets + -/// 32) must match the encoder exactly or the shared index space drifts. -/// -internal sealed class HpackDynamicTable -{ - private const int EntryOverhead = 32; // RFC 7541 §4.1 - - // _entries[0] is the newest entry (dynamic index 1 / combined index 62). - private readonly List _entries = []; - - public HpackDynamicTable(int maxSize) - { - MaxSize = maxSize; - } - - /// Current upper bound on , in octets. - public int MaxSize { get; private set; } - - /// Sum of all entry sizes currently held. - public int Size { get; private set; } - - /// Number of entries currently held. - public int Count => _entries.Count; - - /// - /// Insert a header field at the front. Evicts oldest entries until it fits; - /// if the entry alone exceeds the table is emptied - /// and nothing is inserted (RFC 7541 §4.4). - /// - public void Add(string name, string value, int nameOctets, int valueOctets) - { - var entrySize = nameOctets + valueOctets + EntryOverhead; - EvictTo(MaxSize - entrySize); - if (entrySize > MaxSize) - { - return; // doesn't fit even in an empty table - } - - _entries.Insert(0, new Entry(name, value, entrySize)); - Size += entrySize; - } - - /// - /// Resize the table per a dynamic table size update (RFC 7541 §6.3), - /// evicting oldest entries as needed to satisfy the new bound. - /// - public void Resize(int newMaxSize) - { - MaxSize = newMaxSize; - EvictTo(MaxSize); - } - - /// - /// Resolve a 1-based dynamic index (1 == newest). Returns false if out of - /// range. - /// - public bool TryGet(int dynamicIndex, out string name, out string value) - { - if (dynamicIndex < 1 || dynamicIndex > _entries.Count) - { - name = string.Empty; - value = string.Empty; - return false; - } - var e = _entries[dynamicIndex - 1]; - name = e.Name; - value = e.Value; - return true; - } - - private void EvictTo(int target) - { - while (Size > target && _entries.Count > 0) - { - var last = _entries[^1]; - _entries.RemoveAt(_entries.Count - 1); - Size -= last.Size; - } - } - - private readonly record struct Entry(string Name, string Value, int Size); -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs b/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs deleted file mode 100644 index dbecbc39..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text; - -namespace Starling.Net.Http.H2.Hpack; - -/// -/// HPACK encoder (RFC 7541 §6). Deliberately simple and stateless: it indexes -/// the static table for exact and name matches but never inserts into a dynamic -/// table, so it carries no per-connection state and cannot drift. String -/// literals are Huffman-coded only when that is strictly shorter. This is a -/// fully conformant encoding — the peer decodes it the same regardless of the -/// indexing strategy we choose. -/// -internal sealed class HpackEncoder -{ - /// Encode a header field list into a single HPACK header block. - public byte[] Encode(IReadOnlyList<(string Name, string Value)> fields) - { - var dst = new List(64); - foreach (var (name, value) in fields) - { - var index = HpackStaticTable.FindIndex(name, value, out var exact); - if (exact) - { - // §6.1 Indexed Header Field. - WriteInteger(dst, index, 7, 0x80); - continue; - } - - if (index > 0) - { - // §6.2.2 Literal without indexing, name from the static table. - WriteInteger(dst, index, 4, 0x00); - } - else - { - // §6.2.2 Literal without indexing, new name. - dst.Add(0x00); - WriteString(dst, name); - } - WriteString(dst, value); - } - return [.. dst]; - } - - private static void WriteInteger(List dst, int value, int prefixBits, byte firstByteHigh) - { - Span tmp = stackalloc byte[HpackInteger.MaxEncodedLength]; - var n = HpackInteger.Encode(tmp, value, prefixBits, firstByteHigh); - for (var i = 0; i < n; i++) - { - dst.Add(tmp[i]); - } - } - - private static void WriteString(List dst, string s) - { - var bytes = Encoding.Latin1.GetBytes(s); - var huffLen = HpackHuffman.EncodedLength(bytes); - - if (huffLen < bytes.Length) - { - WriteInteger(dst, huffLen, 7, 0x80); // H flag set - var buf = new byte[huffLen]; - HpackHuffman.Encode(bytes, buf); - dst.AddRange(buf); - } - else - { - WriteInteger(dst, bytes.Length, 7, 0x00); - dst.AddRange(bytes); - } - } -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs b/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs deleted file mode 100644 index 4e6ca76c..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs +++ /dev/null @@ -1,215 +0,0 @@ -namespace Starling.Net.Http.H2.Hpack; - -/// -/// HPACK Huffman codec (RFC 7541 Appendix B). Decode walks a bit-trie built -/// once at static init; encode emits MSB-first codes. The canonical 257-symbol -/// table (256 octet symbols + EOS) is embedded verbatim from the RFC. -/// -internal static class HpackHuffman -{ - /// Symbol index of the End-Of-String code; never appears decoded. - private const int Eos = 256; - - // (code right-aligned in a uint, bit length). Index = symbol value. - private static readonly (uint Code, int Bits)[] Table = - [ - (0x00001ff8u, 13), (0x007fffd8u, 23), (0x0fffffe2u, 28), (0x0fffffe3u, 28), (0x0fffffe4u, 28), - (0x0fffffe5u, 28), (0x0fffffe6u, 28), (0x0fffffe7u, 28), (0x0fffffe8u, 28), (0x00ffffeau, 24), - (0x3ffffffcu, 30), (0x0fffffe9u, 28), (0x0fffffeau, 28), (0x3ffffffdu, 30), (0x0fffffebu, 28), - (0x0fffffecu, 28), (0x0fffffedu, 28), (0x0fffffeeu, 28), (0x0fffffefu, 28), (0x0ffffff0u, 28), - (0x0ffffff1u, 28), (0x0ffffff2u, 28), (0x3ffffffeu, 30), (0x0ffffff3u, 28), (0x0ffffff4u, 28), - (0x0ffffff5u, 28), (0x0ffffff6u, 28), (0x0ffffff7u, 28), (0x0ffffff8u, 28), (0x0ffffff9u, 28), - (0x0ffffffau, 28), (0x0ffffffbu, 28), (0x00000014u, 6), (0x000003f8u, 10), (0x000003f9u, 10), - (0x00000ffau, 12), (0x00001ff9u, 13), (0x00000015u, 6), (0x000000f8u, 8), (0x000007fau, 11), - (0x000003fau, 10), (0x000003fbu, 10), (0x000000f9u, 8), (0x000007fbu, 11), (0x000000fau, 8), - (0x00000016u, 6), (0x00000017u, 6), (0x00000018u, 6), (0x00000000u, 5), (0x00000001u, 5), - (0x00000002u, 5), (0x00000019u, 6), (0x0000001au, 6), (0x0000001bu, 6), (0x0000001cu, 6), - (0x0000001du, 6), (0x0000001eu, 6), (0x0000001fu, 6), (0x0000005cu, 7), (0x000000fbu, 8), - (0x00007ffcu, 15), (0x00000020u, 6), (0x00000ffbu, 12), (0x000003fcu, 10), (0x00001ffau, 13), - (0x00000021u, 6), (0x0000005du, 7), (0x0000005eu, 7), (0x0000005fu, 7), (0x00000060u, 7), - (0x00000061u, 7), (0x00000062u, 7), (0x00000063u, 7), (0x00000064u, 7), (0x00000065u, 7), - (0x00000066u, 7), (0x00000067u, 7), (0x00000068u, 7), (0x00000069u, 7), (0x0000006au, 7), - (0x0000006bu, 7), (0x0000006cu, 7), (0x0000006du, 7), (0x0000006eu, 7), (0x0000006fu, 7), - (0x00000070u, 7), (0x00000071u, 7), (0x00000072u, 7), (0x000000fcu, 8), (0x00000073u, 7), - (0x000000fdu, 8), (0x00001ffbu, 13), (0x0007fff0u, 19), (0x00001ffcu, 13), (0x00003ffcu, 14), - (0x00000022u, 6), (0x00007ffdu, 15), (0x00000003u, 5), (0x00000023u, 6), (0x00000004u, 5), - (0x00000024u, 6), (0x00000005u, 5), (0x00000025u, 6), (0x00000026u, 6), (0x00000027u, 6), - (0x00000006u, 5), (0x00000074u, 7), (0x00000075u, 7), (0x00000028u, 6), (0x00000029u, 6), - (0x0000002au, 6), (0x00000007u, 5), (0x0000002bu, 6), (0x00000076u, 7), (0x0000002cu, 6), - (0x00000008u, 5), (0x00000009u, 5), (0x0000002du, 6), (0x00000077u, 7), (0x00000078u, 7), - (0x00000079u, 7), (0x0000007au, 7), (0x0000007bu, 7), (0x00007ffeu, 15), (0x000007fcu, 11), - (0x00003ffdu, 14), (0x00001ffdu, 13), (0x0ffffffcu, 28), (0x000fffe6u, 20), (0x003fffd2u, 22), - (0x000fffe7u, 20), (0x000fffe8u, 20), (0x003fffd3u, 22), (0x003fffd4u, 22), (0x003fffd5u, 22), - (0x007fffd9u, 23), (0x003fffd6u, 22), (0x007fffdau, 23), (0x007fffdbu, 23), (0x007fffdcu, 23), - (0x007fffddu, 23), (0x007fffdeu, 23), (0x00ffffebu, 24), (0x007fffdfu, 23), (0x00ffffecu, 24), - (0x00ffffedu, 24), (0x003fffd7u, 22), (0x007fffe0u, 23), (0x00ffffeeu, 24), (0x007fffe1u, 23), - (0x007fffe2u, 23), (0x007fffe3u, 23), (0x007fffe4u, 23), (0x001fffdcu, 21), (0x003fffd8u, 22), - (0x007fffe5u, 23), (0x003fffd9u, 22), (0x007fffe6u, 23), (0x007fffe7u, 23), (0x00ffffefu, 24), - (0x003fffdau, 22), (0x001fffddu, 21), (0x000fffe9u, 20), (0x003fffdbu, 22), (0x003fffdcu, 22), - (0x007fffe8u, 23), (0x007fffe9u, 23), (0x001fffdeu, 21), (0x007fffeau, 23), (0x003fffddu, 22), - (0x003fffdeu, 22), (0x00fffff0u, 24), (0x001fffdfu, 21), (0x003fffdfu, 22), (0x007fffebu, 23), - (0x007fffecu, 23), (0x001fffe0u, 21), (0x001fffe1u, 21), (0x003fffe0u, 22), (0x001fffe2u, 21), - (0x007fffedu, 23), (0x003fffe1u, 22), (0x007fffeeu, 23), (0x007fffefu, 23), (0x000fffeau, 20), - (0x003fffe2u, 22), (0x003fffe3u, 22), (0x003fffe4u, 22), (0x007ffff0u, 23), (0x003fffe5u, 22), - (0x003fffe6u, 22), (0x007ffff1u, 23), (0x03ffffe0u, 26), (0x03ffffe1u, 26), (0x000fffebu, 20), - (0x0007fff1u, 19), (0x003fffe7u, 22), (0x007ffff2u, 23), (0x003fffe8u, 22), (0x01ffffecu, 25), - (0x03ffffe2u, 26), (0x03ffffe3u, 26), (0x03ffffe4u, 26), (0x07ffffdeu, 27), (0x07ffffdfu, 27), - (0x03ffffe5u, 26), (0x00fffff1u, 24), (0x01ffffedu, 25), (0x0007fff2u, 19), (0x001fffe3u, 21), - (0x03ffffe6u, 26), (0x07ffffe0u, 27), (0x07ffffe1u, 27), (0x03ffffe7u, 26), (0x07ffffe2u, 27), - (0x00fffff2u, 24), (0x001fffe4u, 21), (0x001fffe5u, 21), (0x03ffffe8u, 26), (0x03ffffe9u, 26), - (0x0ffffffdu, 28), (0x07ffffe3u, 27), (0x07ffffe4u, 27), (0x07ffffe5u, 27), (0x000fffecu, 20), - (0x00fffff3u, 24), (0x000fffedu, 20), (0x001fffe6u, 21), (0x003fffe9u, 22), (0x001fffe7u, 21), - (0x001fffe8u, 21), (0x007ffff3u, 23), (0x003fffeau, 22), (0x003fffebu, 22), (0x01ffffeeu, 25), - (0x01ffffefu, 25), (0x00fffff4u, 24), (0x00fffff5u, 24), (0x03ffffeau, 26), (0x007ffff4u, 23), - (0x03ffffebu, 26), (0x07ffffe6u, 27), (0x03ffffecu, 26), (0x03ffffedu, 26), (0x07ffffe7u, 27), - (0x07ffffe8u, 27), (0x07ffffe9u, 27), (0x07ffffeau, 27), (0x07ffffebu, 27), (0x0ffffffeu, 28), - (0x07ffffecu, 27), (0x07ffffedu, 27), (0x07ffffeeu, 27), (0x07ffffefu, 27), (0x07fffff0u, 27), - (0x03ffffeeu, 26), (0x3fffffffu, 30), - ]; - - // Bit-trie: two arrays of child indices (0-bit / 1-bit) plus a per-node - // symbol (-1 for internal nodes). Node 0 is the root. - private static readonly int[] Zero; - private static readonly int[] One; - private static readonly int[] Symbol; - - static HpackHuffman() - { - // Worst case one internal node per code bit; over-allocate then trim. - var capacity = 1; - foreach (var (_, bits) in Table) - { - capacity += bits; - } - - Zero = new int[capacity]; - One = new int[capacity]; - Symbol = new int[capacity]; - Array.Fill(Zero, -1); - Array.Fill(One, -1); - Array.Fill(Symbol, -1); - - var next = 1; // node 0 is the root - for (var sym = 0; sym < Table.Length; sym++) - { - var (code, bits) = Table[sym]; - var node = 0; - for (var i = bits - 1; i >= 0; i--) - { - var bit = (code >> i) & 1; - ref var edge = ref (bit == 0 ? ref Zero[node] : ref One[node]); - if (edge < 0) - { - edge = next++; - } - node = edge; - } - Symbol[node] = sym; - } - } - - /// - /// Decode a Huffman-coded octet string. Returns false on any RFC 7541 - /// §5.2 violation: an EOS symbol appearing in the stream, padding longer - /// than 7 bits, or padding that is not the MSBs of the all-ones EOS code. - /// - public static bool TryDecode(ReadOnlySpan input, out byte[] output) - { - var sink = new List(input.Length * 8 / 5 + 4); - var node = 0; - var bitsInNode = 0; - var allOnesSinceRoot = true; - - foreach (var b in input) - { - for (var i = 7; i >= 0; i--) - { - var bit = (b >> i) & 1; - if (node == 0) - { - bitsInNode = 0; - allOnesSinceRoot = true; - } - bitsInNode++; - if (bit == 0) - { - allOnesSinceRoot = false; - } - - node = bit == 0 ? Zero[node] : One[node]; - if (node < 0) - { - output = []; - return false; // no such code path - } - - if (Symbol[node] >= 0) - { - if (Symbol[node] == Eos) - { - output = []; - return false; // EOS must never be encoded - } - sink.Add((byte)Symbol[node]); - node = 0; - } - } - } - - // Trailing bits must be a (<=7-bit) prefix of EOS — i.e. all ones. - if (node != 0 && (bitsInNode > 7 || !allOnesSinceRoot)) - { - output = []; - return false; - } - - output = [.. sink]; - return true; - } - - /// Number of bytes the Huffman encoding of occupies. - public static int EncodedLength(ReadOnlySpan src) - { - var bits = 0L; - foreach (var b in src) - { - bits += Table[b].Bits; - } - - return (int)((bits + 7) / 8); - } - - /// - /// Huffman-encode into , - /// padding the final byte with the MSBs of the EOS code (all ones). - /// Returns the number of bytes written. - /// - public static int Encode(ReadOnlySpan src, Span dst) - { - var pos = 0; - ulong acc = 0; - var accBits = 0; - - foreach (var b in src) - { - var (code, bits) = Table[b]; - acc = (acc << bits) | code; - accBits += bits; - while (accBits >= 8) - { - accBits -= 8; - dst[pos++] = (byte)(acc >> accBits); - } - } - - if (accBits > 0) - { - // Pad the remaining bits with 1s (EOS prefix). - var pad = 8 - accBits; - acc = (acc << pad) | ((1UL << pad) - 1); - dst[pos++] = (byte)acc; - } - - return pos; - } -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs b/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs deleted file mode 100644 index 922d89f1..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs +++ /dev/null @@ -1,97 +0,0 @@ -namespace Starling.Net.Http.H2.Hpack; - -/// -/// HPACK variable-length integer representation (RFC 7541 §5.1). Integers are -/// stored in an N-bit prefix of the first octet; values that don't fit spill -/// into following octets, 7 bits at a time, little-endian, with a continuation -/// bit in the MSB. -/// -internal static class HpackInteger -{ - /// - /// Decode an integer with an -bit prefix - /// starting at . On success advances - /// past the integer and returns true. Returns - /// false on truncation or an over-long encoding (> 32 bits of payload). - /// - public static bool TryDecode( - ReadOnlySpan buf, ref int offset, int prefixBits, out int value) - { - value = 0; - if (offset >= buf.Length) - { - return false; - } - - var max = (1 << prefixBits) - 1; - var prefix = buf[offset] & max; - offset++; - if (prefix < max) - { - value = prefix; - return true; - } - - // Continuation octets: 7 bits each, low-order first. - long result = max; - var shift = 0; - while (true) - { - if (offset >= buf.Length) - { - return false; - } - - var b = buf[offset++]; - result += (long)(b & 0x7f) << shift; - if (result > int.MaxValue) - { - return false; // guard against overflow / DoS - } - - if ((b & 0x80) == 0) - { - break; - } - - shift += 7; - if (shift >= 32) - { - return false; - } - } - - value = (int)result; - return true; - } - - /// - /// Encode into using an - /// -bit prefix. - /// supplies the bits above the prefix in the first octet (e.g. the 0x80 - /// "indexed" flag). Returns the number of bytes written. - /// - public static int Encode(Span dst, int value, int prefixBits, byte firstByteHigh) - { - var max = (1 << prefixBits) - 1; - if (value < max) - { - dst[0] = (byte)(firstByteHigh | value); - return 1; - } - - dst[0] = (byte)(firstByteHigh | max); - var pos = 1; - var remaining = value - max; - while (remaining >= 0x80) - { - dst[pos++] = (byte)((remaining & 0x7f) | 0x80); - remaining >>= 7; - } - dst[pos++] = (byte)remaining; - return pos; - } - - /// Worst-case byte count for encoding a 32-bit value (1 prefix + 5 continuation). - public const int MaxEncodedLength = 6; -} diff --git a/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs b/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs deleted file mode 100644 index bf604e38..00000000 --- a/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs +++ /dev/null @@ -1,125 +0,0 @@ -namespace Starling.Net.Http.H2.Hpack; - -/// -/// HPACK static table (RFC 7541 Appendix A). 61 predefined header field -/// entries, addressed by 1-based index in the combined index space (the -/// dynamic table follows immediately after, starting at index 62). -/// -internal static class HpackStaticTable -{ - /// Number of entries in the static table (RFC 7541 §2.3.1). - public const int Count = 61; - - // Index 1 == Entries[0]. Value is the empty string when the table defines - // no value for the entry (e.g. ":authority"). - private static readonly (string Name, string Value)[] Entries = - [ - (":authority", ""), - (":method", "GET"), - (":method", "POST"), - (":path", "/"), - (":path", "/index.html"), - (":scheme", "http"), - (":scheme", "https"), - (":status", "200"), - (":status", "204"), - (":status", "206"), - (":status", "304"), - (":status", "400"), - (":status", "404"), - (":status", "500"), - ("accept-charset", ""), - ("accept-encoding", "gzip, deflate"), - ("accept-language", ""), - ("accept-ranges", ""), - ("accept", ""), - ("access-control-allow-origin", ""), - ("age", ""), - ("allow", ""), - ("authorization", ""), - ("cache-control", ""), - ("content-disposition", ""), - ("content-encoding", ""), - ("content-language", ""), - ("content-length", ""), - ("content-location", ""), - ("content-range", ""), - ("content-type", ""), - ("cookie", ""), - ("date", ""), - ("etag", ""), - ("expect", ""), - ("expires", ""), - ("from", ""), - ("host", ""), - ("if-match", ""), - ("if-modified-since", ""), - ("if-none-match", ""), - ("if-range", ""), - ("if-unmodified-since", ""), - ("last-modified", ""), - ("link", ""), - ("location", ""), - ("max-forwards", ""), - ("proxy-authenticate", ""), - ("proxy-authorization", ""), - ("range", ""), - ("referer", ""), - ("refresh", ""), - ("retry-after", ""), - ("server", ""), - ("set-cookie", ""), - ("strict-transport-security", ""), - ("transfer-encoding", ""), - ("user-agent", ""), - ("vary", ""), - ("via", ""), - ("www-authenticate", ""), - ]; - - /// - /// Look up an entry by its 1-based static index. Returns false when the - /// index is out of range (1..61). - /// - public static bool TryGet(int index, out string name, out string value) - { - if (index is < 1 or > Count) - { - name = string.Empty; - value = string.Empty; - return false; - } - (name, value) = Entries[index - 1]; - return true; - } - - /// - /// Find a static index for a header. Returns the index of an exact - /// name+value match if one exists; otherwise the index of the first - /// name-only match; otherwise 0. reports whether - /// the returned index also matched the value. - /// - public static int FindIndex(string name, string value, out bool exact) - { - var nameOnly = 0; - for (var i = 0; i < Entries.Length; i++) - { - if (!string.Equals(Entries[i].Name, name, StringComparison.Ordinal)) - { - continue; - } - - if (string.Equals(Entries[i].Value, value, StringComparison.Ordinal)) - { - exact = true; - return i + 1; - } - if (nameOnly == 0) - { - nameOnly = i + 1; - } - } - exact = false; - return nameOnly; - } -} diff --git a/src/Starling.Net/Http/HttpError.cs b/src/Starling.Net/Http/HttpError.cs deleted file mode 100644 index 848dd0a9..00000000 --- a/src/Starling.Net/Http/HttpError.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Starling.Net.Http; - -public enum HttpError -{ - /// Malformed status line. - BadStatusLine, - /// Malformed header (missing colon, invalid token, etc.). - BadHeader, - /// Chunked framing was syntactically broken. - BadChunkedFraming, - /// Connection closed before the response was complete. - UnexpectedEof, - /// Response header block exceeded the configured size cap. - HeadersTooLarge, - /// Response body exceeded the configured size cap. - BodyTooLarge, - /// Content-Encoding referenced an algorithm we don't support. - UnsupportedEncoding, - /// Content-Encoding payload failed to decode (truncated/corrupt). - DecodeFailed, - /// An IO error happened on the underlying transport. - TransportFailure, -} diff --git a/src/Starling.Net/Http/IHttpTransport.cs b/src/Starling.Net/Http/IHttpTransport.cs deleted file mode 100644 index 9b703c12..00000000 --- a/src/Starling.Net/Http/IHttpTransport.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Starling.Net.Tcp; - -namespace Starling.Net.Http; - -/// -/// A live, in-use connection over which one HTTP/1.1 request/response cycle -/// can be conducted. May be plain TCP or TLS-wrapped TCP. Owned either by the -/// caller (when freshly dialed) or by a (when -/// it's an idle, kept-alive transport awaiting reuse). -/// -/// -/// The concrete implementations wrap an plus -/// optionally a TLS transport, and expose the resulting byte -/// that the HTTP layer reads/writes through. -/// Disposing the transport tears down the TLS session (if any) and the -/// underlying TCP socket — there is no "soft" close that keeps the socket -/// alive. -/// -public interface IHttpTransport : IAsyncDisposable -{ - /// The origin (scheme/host/port) this transport is bound to. - OriginKey Origin { get; } - - /// Byte stream the HTTP request writer / response parser operate on. - Stream Stream { get; } - - /// - /// The ALPN protocol negotiated during the TLS handshake (e.g. "h2" - /// or "http/1.1"), or null for plain HTTP / when no ALPN was agreed. - /// Selects whether the HTTP/2 or HTTP/1.1 path drives this transport. - /// - string? Alpn { get; } - - /// The verified leaf certificate for a TLS transport, else null. - Starling.Net.Tls.CertificateSummary? PeerCertificate { get; } - - /// True until either side closes the connection. - bool IsOpen { get; } -} diff --git a/src/Starling.Net/Http/OriginKey.cs b/src/Starling.Net/Http/OriginKey.cs deleted file mode 100644 index 5382c174..00000000 --- a/src/Starling.Net/Http/OriginKey.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Starling.Net.Http; - -/// -/// (scheme, host, port) tuple identifying an HTTP origin for connection-pooling -/// purposes. Matches RFC 6454 §4's "origin" projection but without the URL's -/// path or query — those don't influence which TCP connection a request can -/// reuse. -/// -/// -/// Scheme and host are case-insensitive per the URL spec; -/// normalises them to lowercase ASCII so equality lookups work without -/// per-call fold-case overhead. -/// -public readonly record struct OriginKey(string Scheme, string Host, int Port) -{ - public static OriginKey Create(string scheme, string host, int port) - { - if (string.IsNullOrEmpty(scheme)) - { - throw new ArgumentException("scheme must be non-empty.", nameof(scheme)); - } - - if (string.IsNullOrEmpty(host)) - { - throw new ArgumentException("host must be non-empty.", nameof(host)); - } - - if (port is < 1 or > 65535) - { - throw new ArgumentOutOfRangeException(nameof(port)); - } - - return new OriginKey(scheme.ToLowerInvariant(), host.ToLowerInvariant(), port); - } - - public override string ToString() => $"{Scheme}://{Host}:{Port}"; -} diff --git a/src/Starling.Net/Http/PooledHttpTransport.cs b/src/Starling.Net/Http/PooledHttpTransport.cs deleted file mode 100644 index e93fb829..00000000 --- a/src/Starling.Net/Http/PooledHttpTransport.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Starling.Net.Tcp; -using Starling.Net.Tls; - -namespace Starling.Net.Http; - -/// -/// Concrete wrapping a TCP connection optionally -/// upgraded to TLS. The instance owns its underlying TCP socket and (if -/// present) ; tears -/// everything down so the same wrapper can never be used twice after a close. -/// -/// -/// The exposed to the HTTP layer is the TLS-wrapped -/// stream for HTTPS or a thin adapter for -/// plain HTTP. Either way, reads/writes go through one byte-oriented stream -/// so the request writer and response parser remain transport-agnostic. -/// -internal sealed class PooledHttpTransport : IHttpTransport -{ - private readonly ITcpConnection _tcp; - private readonly BcTlsTransport? _tls; - private readonly TcpConnectionStream? _plainStream; - private bool _disposed; - - public OriginKey Origin { get; } - public Stream Stream { get; } - public string? Alpn { get; } - public CertificateSummary? PeerCertificate { get; } - - public bool IsOpen => !_disposed && _tcp.IsOpen; - - private PooledHttpTransport( - OriginKey origin, - ITcpConnection tcp, - BcTlsTransport? tls, - TcpConnectionStream? plainStream, - Stream stream, - string? alpn, - CertificateSummary? peerCertificate) - { - Origin = origin; - _tcp = tcp; - _tls = tls; - _plainStream = plainStream; - Stream = stream; - Alpn = alpn; - PeerCertificate = peerCertificate; - } - - public static PooledHttpTransport ForPlainHttp( - OriginKey origin, ITcpConnection tcp) - { - ArgumentNullException.ThrowIfNull(tcp); - var stream = new TcpConnectionStream(tcp); - return new PooledHttpTransport( - origin, tcp, tls: null, plainStream: stream, stream, alpn: null, peerCertificate: null); - } - - public static PooledHttpTransport ForTls( - OriginKey origin, ITcpConnection tcp, BcTlsTransport tls) - { - ArgumentNullException.ThrowIfNull(tcp); - ArgumentNullException.ThrowIfNull(tls); - return new PooledHttpTransport( - origin, tcp, tls, plainStream: null, tls.Stream, - tls.NegotiatedApplicationProtocol, tls.PeerCertificate); - } - - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - if (_tls is not null) - { - // Disposing the TLS transport also disposes the wrapped TCP stream - // (which in turn disposes the connection). - _tls.Dispose(); - } - else if (_plainStream is not null) - { - // Plain stream owns the connection via its Dispose override. - _plainStream.Dispose(); - } - else - { - await _tcp.DisposeAsync().ConfigureAwait(false); - } - } -} diff --git a/src/Starling.Net/Http/SecureConnector.cs b/src/Starling.Net/Http/SecureConnector.cs new file mode 100644 index 00000000..04a790f9 --- /dev/null +++ b/src/Starling.Net/Http/SecureConnector.cs @@ -0,0 +1,128 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using Starling.Common.Diagnostics; +using Starling.Net.Tls; + +namespace Starling.Net.Http; + +/// +/// Opens the TCP + TLS connections behind a . +/// Wired in as so we keep three +/// things the browser needs that the default connect path hides: our bundled +/// trust anchors (the OS store is not the authority), the verified leaf +/// certificate surfaced to the shell lock UI, and per-phase (dns / tcp / tls) +/// telemetry spans. +/// +internal sealed class SecureConnector +{ + private readonly RootCertificates _roots; + private readonly List _alpn; + // Latest verified leaf per origin ("host:port"). A connection is only + // recorded after its chain validated, so a present entry always describes a + // certificate that passed CertificateVerifier. Reused across the origin's + // pooled connections, which all present the same certificate. + private readonly ConcurrentDictionary _certificates = new(StringComparer.Ordinal); + + public SecureConnector(RootCertificates roots, IReadOnlyList alpnProtocols) + { + _roots = roots; + _alpn = alpnProtocols.Select(p => new SslApplicationProtocol(p)).ToList(); + } + + public CertificateSummary? CertificateFor(string host, int port) => + _certificates.TryGetValue(Key(host, port), out var summary) ? summary : null; + + public async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct) + { + var host = context.DnsEndPoint.Host; + var port = context.DnsEndPoint.Port; + var isHttps = context.InitialRequestMessage.RequestUri is { Scheme: "https" }; + + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + IPAddress[] addresses; + using (StarlingTelemetry.Span("net", "dns")) + { + StarlingTelemetry.Counter("net.dns.resolutions", 1); + addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false); + } + + using (StarlingTelemetry.Span("net", "tcp_connect")) + { + StarlingTelemetry.Counter("net.tcp.connects", 1); + await socket.ConnectAsync(addresses, port, ct).ConfigureAwait(false); + } + + var networkStream = new NetworkStream(socket, ownsSocket: true); + if (!isHttps) + { + return networkStream; + } + + var ssl = new SslStream(networkStream, leaveInnerStreamOpen: false); + var sslOptions = new SslClientAuthenticationOptions + { + TargetHost = host, + EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12, + ApplicationProtocols = _alpn, + RemoteCertificateValidationCallback = (_, cert, chain, _) => Verify(host, cert, chain), + }; + + try + { + using (StarlingTelemetry.Span("net", "tls_handshake")) + { + StarlingTelemetry.Counter("net.tls.handshakes", 1); + await ssl.AuthenticateAsClientAsync(sslOptions, ct).ConfigureAwait(false); + } + } + catch + { + StarlingTelemetry.Counter("net.tls.failures", 1); + await ssl.DisposeAsync().ConfigureAwait(false); + throw; + } + + if (ssl.RemoteCertificate is { } leaf) + { + _certificates[Key(host, port)] = CertificateVerifier.Summarize(AsCertificate2(leaf)); + } + return ssl; + } + catch + { + socket.Dispose(); + throw; + } + } + + private bool Verify(string host, X509Certificate? cert, X509Chain? chain) + { + if (cert is null) + { + return false; + } + + X509Certificate2Collection? intermediates = null; + if (chain is { ChainElements.Count: > 0 }) + { + intermediates = new X509Certificate2Collection(); + foreach (var element in chain.ChainElements) + { + intermediates.Add(element.Certificate); + } + } + + return CertificateVerifier.Verify(AsCertificate2(cert), intermediates, host, _roots); + } + + private static X509Certificate2 AsCertificate2(X509Certificate cert) => + cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.Export(X509ContentType.Cert)); + + private static string Key(string host, int port) => $"{host}:{port}"; +} diff --git a/src/Starling.Net/Starling.Net.csproj b/src/Starling.Net/Starling.Net.csproj index f7ffdf83..d678731d 100644 --- a/src/Starling.Net/Starling.Net.csproj +++ b/src/Starling.Net/Starling.Net.csproj @@ -3,7 +3,6 @@ false - diff --git a/src/Starling.Net/StarlingHttpClient.cs b/src/Starling.Net/StarlingHttpClient.cs index ac1093ae..14c10ee6 100644 --- a/src/Starling.Net/StarlingHttpClient.cs +++ b/src/Starling.Net/StarlingHttpClient.cs @@ -1,14 +1,11 @@ using System.Diagnostics; +using System.Net; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Starling.Common; using Starling.Common.Diagnostics; -using Starling.Net.Dns; using Starling.Net.Http; using Starling.Net.Http.Cookies; -using Starling.Net.Http.H1; -using Starling.Net.Http.H2; -using Starling.Net.Tcp; using Starling.Net.Tls; using StarlingUrl = global::Starling.Url.Url; @@ -18,43 +15,28 @@ internal static partial class StarlingHttpClientLog { [LoggerMessage(Level = LogLevel.Warning, Message = "HTTP request failed: {Error}")] public static partial void RequestFailed(ILogger logger, string error); - - [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during cleanup")] - public static partial void SafeDisposeFailed(ILogger logger, Exception ex); } /// -/// Top-level HTTP client. Resolves a URL to a TCP endpoint, opens a transport -/// (TLS for https, plain for http), and speaks HTTP/2 or HTTP/1.1 depending on -/// the TLS application protocol negotiated during the handshake. Over HTTP/1.1, -/// sequential requests to the same origin reuse a pooled TCP+TLS transport when -/// both sides advertise keep-alive. Over HTTP/2, requests to the -/// same origin are multiplexed onto a single shared connection held by the -/// . -/// -/// -/// For a single GET against https://example.com the data flow is: +/// Top-level HTTP client. A thin browser-policy layer over .NET's +/// (HTTP/1.1 + HTTP/2 over a +/// ). We keep the pieces a browser must own and +/// that the default handler would otherwise do automatically: /// -/// → A/AAAA records -/// -/// → negotiated TLS stream -/// → wire bytes onto the stream -/// → fully buffered +/// Redirects are not auto-followed — callers observe each hop +/// ( is false). +/// Cookies come from our , not +/// (which lacks PSL / prefix rules). +/// Server certificates chain to our bundled trust anchors via +/// , and the verified leaf is surfaced on the +/// response for the shell lock UI. /// -/// For HTTP/1.1 a sits in front of the dialer: -/// every send asks the pool first, and clean responses with a definite body -/// length and keep-alive headers return the transport to the pool. For HTTP/2, -/// the post-handshake transport is handed to an and -/// subsequent requests to the same origin are multiplexed onto it. -/// +/// public sealed class StarlingHttpClient : IDisposable { private readonly StarlingHttpClientOptions _options; - private readonly DnsResolver _dns; - private readonly TcpDialer _dialer; - private readonly ConnectionPool _pool; - private readonly ILoggerFactory _loggerFactory; - private readonly H2ConnectionManager _h2; + private readonly System.Net.Http.HttpClient _http; + private readonly SecureConnector _connector; private readonly ILogger _log; private bool _disposed; @@ -63,12 +45,29 @@ public StarlingHttpClient() : this(new StarlingHttpClientOptions()) { } public StarlingHttpClient(StarlingHttpClientOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); - _loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance; - _log = _loggerFactory.CreateLogger(); - _h2 = new H2ConnectionManager(_loggerFactory.CreateLogger()); - _dns = options.DnsResolver ?? new DnsResolver(new UdpDnsTransport()); - _dialer = new TcpDialer(_dns) { ConnectTimeout = options.ConnectTimeout }; - _pool = options.ConnectionPool ?? new ConnectionPool(); + var loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance; + _log = loggerFactory.CreateLogger(); + _connector = new SecureConnector(RootCertificates.SystemTrust, options.AlpnProtocols); + + var handler = new SocketsHttpHandler + { + // Browser policy lives above the transport — the engine follows + // redirects, our jar drives cookies. Let .NET only decompress. + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.All, + ConnectTimeout = options.ConnectTimeout, + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60), + EnableMultipleHttp2Connections = true, + ConnectCallback = _connector.ConnectAsync, + }; + + _http = new System.Net.Http.HttpClient(handler, disposeHandler: true) + { + // Per-request timeouts are enforced with a linked CTS in SendAsync so + // a timeout is distinguishable from caller cancellation. + Timeout = Timeout.InfiniteTimeSpan, + }; } /// The User-Agent presented on the wire. Surfaced so the JS layer @@ -76,10 +75,6 @@ public StarlingHttpClient(StarlingHttpClientOptions options) /// send (sites compare the two). public string UserAgent => _options.UserAgent; - /// Idle connection pool. Exposed mainly for tests asserting on - /// reuse / capacity behaviour. - public ConnectionPool ConnectionPool => _pool; - public Task> GetAsync(string url, CancellationToken ct = default) { var parsed = global::Starling.Url.UrlParser.Parse(url); @@ -111,23 +106,17 @@ public async Task> SendAsync( return Result.Err(NetworkError.BadUrl); } - var port = url.Port ?? url.DefaultPort - ?? (url.IsHttps ? 443 : url.IsHttp ? 80 : 0); + var port = url.Port ?? url.DefaultPort ?? (url.IsHttps ? 443 : 80); if (port is < 1 or > 65535) { return Result.Err(NetworkError.BadUrl); } - var origin = OriginKey.Create(url.IsHttps ? "https" : "http", url.Host, port); - - // Present a recognised browser UA on every request (document, subresource, - // and JS fetch/XHR) unless the caller forced one. Both the H1 writer and - // the H2 header builder honour an existing User-Agent header, so this is - // the single wire-side source of truth. See StarlingHttpClientOptions. if (!request.Headers.Contains("User-Agent")) { request.Headers.Set("User-Agent", _options.UserAgent); } + ApplyRequestCookies(request, url); using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(ct); requestCts.CancelAfter(_options.RequestTimeout); @@ -135,372 +124,142 @@ public async Task> SendAsync( using var httpSpan = StarlingTelemetry.Span("net", "http"); Activity.Current?.SetTag("http.method", request.Method); Activity.Current?.SetTag("http.url", url.ToString()); - Activity.Current?.SetTag("server.address", origin.Host); - Activity.Current?.SetTag("server.port", origin.Port); + Activity.Current?.SetTag("server.address", url.Host); + Activity.Current?.SetTag("server.port", port); StarlingTelemetry.Counter("net.http.requests", 1); - try + using var message = BuildRequestMessage(request); + if (!request.Body.IsEmpty) { - // 0. Reuse a live, multiplexed HTTP/2 connection if one is open for - // this origin. A GOAWAY/closed connection between requests yields - // a retryable failure; we then fall through to a fresh dial. - if (_h2.TryGet(origin) is { } h2Existing) - { - var h2Result = await SendOverH2Async( - h2Existing, request, url, reused: true, requestCts.Token).ConfigureAwait(false); - if (h2Result is { } got) - { - RecordResponseTags(got); - return got; - } - // Connection went away before our request was processed — re-dial. - } - - // 1. Try the pool first. A pooled transport may turn out to have - // been closed by the peer between the prior response and this - // request; if our send raises an IO error — or reads back zero - // bytes when we were expecting a status line — we fall through - // to a fresh dial. - var pooled = _pool.TryAcquire(origin); - if (pooled is not null) - { - StarlingTelemetry.Counter("net.http.connection_reused", 1); - Activity.Current?.SetTag("connection.reused", true); - var pooledOutcome = await TrySendOnTransportAsync( - pooled, request, url, fromPool: true, requestCts.Token).ConfigureAwait(false); - if (pooledOutcome.UsedTransport) - { - RecordResponseTags(pooledOutcome.Result); - return pooledOutcome.Result; - } - // Otherwise the connection was unusable (closed/IO) — dispose - // and retry with a fresh dial. Re-tag the span: this request - // ended up paying for a fresh handshake despite the pool hit. - Activity.Current?.SetTag("connection.reused", false); - await SafeDisposeAsync(pooled).ConfigureAwait(false); - } - else - { - Activity.Current?.SetTag("connection.reused", false); - } - - // 2. Dial + (optionally) TLS-handshake a new transport. - var dialed = await DialAsync(url, origin, requestCts.Token).ConfigureAwait(false); - if (dialed.IsErr) - { - RecordError(dialed.Error); - return Result.Err(dialed.Error); - } - - StarlingTelemetry.Counter("net.http.connection_opened", 1); - - var fresh = dialed.Value; - - // ALPN decides the protocol. For "h2" the transport becomes a - // multiplexed connection owned by the H2 manager; for everything - // else we speak HTTP/1.1 over the transport's byte stream. - if (string.Equals(fresh.Alpn, "h2", StringComparison.Ordinal)) - { - var conn = await H2Connection.StartAsync( - fresh, origin, _loggerFactory.CreateLogger(), c => _h2.Remove(origin, c), requestCts.Token).ConfigureAwait(false); - var winner = _h2.Adopt(origin, conn); - if (!ReferenceEquals(winner, conn)) - { - await conn.DisposeAsync().ConfigureAwait(false); // lost the race; use the incumbent - } - - var h2Fresh = await SendOverH2Async( - winner, request, url, reused: false, requestCts.Token).ConfigureAwait(false); - var h2Final = h2Fresh ?? Result.Err(NetworkError.TransportFailure); - RecordResponseTags(h2Final); - return h2Final; - } + StarlingTelemetry.Counter("net.http.bytes_out", request.Body.Length); + } - var freshOutcome = await TrySendOnTransportAsync( - fresh, request, url, fromPool: false, requestCts.Token).ConfigureAwait(false); - if (!freshOutcome.UsedTransport) + try + { + using var response = await _http.SendAsync( + message, HttpCompletionOption.ResponseHeadersRead, requestCts.Token).ConfigureAwait(false); + + var body = await response.Content.ReadAsByteArrayAsync(requestCts.Token).ConfigureAwait(false); + var result = BuildResponse(response, url, port, body); + + StoreResponseCookies(result, url); + Activity.Current?.SetTag("http.status_code", result.StatusCode); + Activity.Current?.SetTag("network.protocol.version", response.Version.ToString()); + Activity.Current?.SetTag("http.response.body.size", body.Length); + StarlingTelemetry.Counter("net.http.bytes_in", body.Length); + if (result.StatusCode >= 500) { - // A brand-new transport that refused to talk: surface a - // transport error and discard. We don't loop again to avoid - // hammering. - await SafeDisposeAsync(fresh).ConfigureAwait(false); - RecordError(NetworkError.TransportFailure); - return Result.Err(NetworkError.TransportFailure); + Activity.Current?.SetStatus(ActivityStatusCode.Error, $"HTTP {result.StatusCode}"); } - RecordResponseTags(freshOutcome.Result); - return freshOutcome.Result; + return Result.Ok(result); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { RecordError(NetworkError.RequestTimeout); return Result.Err(NetworkError.RequestTimeout); } - } - - private void RecordResponseTags(Result result) - { - if (result.IsErr) + catch (HttpRequestException ex) { - RecordError(result.Error); - return; + var error = MapException(ex); + RecordError(error); + return Result.Err(error); } - var response = result.Value; - Activity.Current?.SetTag("http.status_code", response.StatusCode); - Activity.Current?.SetTag("http.response.body.size", response.Body.Length); - StarlingTelemetry.Counter("net.http.bytes_in", response.Body.Length); - if (response.StatusCode >= 500) - { - Activity.Current?.SetStatus(ActivityStatusCode.Error, $"HTTP {response.StatusCode}"); - } - } - - private void RecordError(NetworkError error) - { - var message = error.ToString(); - StarlingTelemetry.Counter("net.http.failures", 1); - Activity.Current?.SetStatus(ActivityStatusCode.Error, message); - StarlingHttpClientLog.RequestFailed(_log, message); } - private async Task> DialAsync( - StarlingUrl url, OriginKey origin, CancellationToken ct) + private HttpRequestMessage BuildRequestMessage(HttpRequest request) { - // DNS resolution span. The TcpDialer drives DNS internally; we mirror - // the work with our own resolve call so the span/counter scope is - // crisp and DNS-only failures are distinguishable from connect-only - // failures. The result feeds DialDirectAsync below. - Result dnsResult; - using (var dnsSpan = StarlingTelemetry.Span("net", "dns")) + var message = new HttpRequestMessage(new HttpMethod(request.Method), request.Url.ToString()) { - Activity.Current?.SetTag("dns.host", origin.Host); - StarlingTelemetry.Counter("net.dns.resolutions", 1); - dnsResult = await _dns.ResolveAsync(origin.Host, ct).ConfigureAwait(false); - if (dnsResult.IsErr) - { - StarlingTelemetry.Counter("net.dns.failures", 1); - Activity.Current?.SetStatus(ActivityStatusCode.Error, dnsResult.Error.ToString()); - return Result.Err(NetworkError.DnsFailure); - } - } + // ALPN chooses the wire protocol; fall back to HTTP/1.1 if the peer + // does not offer HTTP/2. + Version = HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionOrLower, + }; - // TCP connect span. Try each resolved address; first success wins. - ITcpConnection? tcp = null; - TcpError? lastTcpError = null; - using (var tcpSpan = StarlingTelemetry.Span("net", "tcp_connect")) + if (!request.Body.IsEmpty) { - Activity.Current?.SetTag("server.address", origin.Host); - Activity.Current?.SetTag("server.port", origin.Port); - StarlingTelemetry.Counter("net.tcp.connects", 1); - - foreach (var ip in dnsResult.Value.Addresses) - { - var endpoint = new System.Net.IPEndPoint(ip, origin.Port); - var dial = await _dialer.DialDirectAsync( - endpoint, - new TcpEndpoint(origin.Host, origin.Port), - ct).ConfigureAwait(false); - if (dial.IsOk) - { - tcp = dial.Value; - break; - } - lastTcpError = dial.Error; - } - - if (tcp is null) - { - StarlingTelemetry.Counter("net.tcp.failures", 1); - var err = lastTcpError == TcpError.Timeout - ? NetworkError.ConnectTimeout - : NetworkError.ConnectFailed; - Activity.Current?.SetStatus(ActivityStatusCode.Error, err.ToString()); - return Result.Err(err); - } + message.Content = new ReadOnlyMemoryContent(request.Body); } - if (url.IsHttps) + foreach (var header in request.Headers) { - using var tlsSpan = StarlingTelemetry.Span("net", "tls_handshake"); - Activity.Current?.SetTag("server.address", origin.Host); - StarlingTelemetry.Counter("net.tls.handshakes", 1); - - var tlsResult = await BcTlsTransport.ConnectAsync( - tcp, - new TlsClientOptions(origin.Host, _options.AlpnProtocols), - ct).ConfigureAwait(false); - if (tlsResult.IsErr) - { - StarlingTelemetry.Counter("net.tls.failures", 1); - await tcp.DisposeAsync().ConfigureAwait(false); - var err = tlsResult.Error == TlsError.CertificateRejected - ? NetworkError.TlsCertificateRejected - : NetworkError.TlsHandshakeFailed; - Activity.Current?.SetStatus(ActivityStatusCode.Error, err.ToString()); - return Result.Err(err); - } - // StarlingTlsClient pins TLS 1.3; tag it post-handshake. - Activity.Current?.SetTag("tls.protocol", "TLSv1.3"); - // ALPN selects HTTP/2 vs HTTP/1.1 downstream (see SendAsync). We - // advertise "h2, http/1.1", so either is expected; an empty/absent - // ALPN falls back to HTTP/1.1. - if (tlsResult.Value.NegotiatedApplicationProtocol is { Length: > 0 } alpn) + // Content-* headers belong on the content object; everything else on + // the request. TryAddWithoutValidation keeps values verbatim. + if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value)) { - Activity.Current?.SetTag("tls.alpn", alpn); + message.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); } - - return Result.Ok( - PooledHttpTransport.ForTls(origin, tcp, tlsResult.Value)); } - return Result.Ok( - PooledHttpTransport.ForPlainHttp(origin, tcp)); + return message; } - /// - /// Write the request, parse the response, and decide whether to return - /// the transport to the pool or close it. The returned - /// flag is false when the - /// caller should retry on a different transport (e.g. the pooled socket - /// was closed between requests); the transport is still owned by the - /// caller in that case and must be disposed. - /// - private async Task TrySendOnTransportAsync( - IHttpTransport transport, - HttpRequest request, - StarlingUrl url, - bool fromPool, - CancellationToken ct) + private HttpResponse BuildResponse(HttpResponseMessage message, StarlingUrl url, int port, byte[] body) { - try + var headers = new HttpHeaders(); + // NonValidated preserves raw values and duplicates (notably repeated + // Set-Cookie), which the fetch/XHR bindings depend on. + foreach (var header in message.Headers.NonValidated) { - ApplyRequestCookies(request, url); - - using (var writeSpan = StarlingTelemetry.Span("net", "h1_request")) - { - StarlingTelemetry.Counter("net.h1.requests_written", 1); - await _options.RequestWriter - .WriteAsync(request, transport.Stream, ct) - .ConfigureAwait(false); - if (!request.Body.IsEmpty) - { - StarlingTelemetry.Counter("net.http.bytes_out", request.Body.Length); - } - } - - Result parseResult; - using (var parseSpan = StarlingTelemetry.Span("net", "h1_response")) - { - StarlingTelemetry.Counter("net.h1.responses_parsed", 1); - parseResult = await _options.ResponseParser - .ParseAsync(transport.Stream, ct).ConfigureAwait(false); - if (parseResult.IsOk) - { - Activity.Current?.SetTag("http.status_code", parseResult.Value.StatusCode); - } - } - - if (parseResult.IsErr) - { - // A pooled connection that the peer closed since the prior - // response will write OK (TCP buffers locally) but read 0 - // bytes — the parser surfaces that as UnexpectedEof. Retry - // on a fresh dial in that case; for everything else (or on - // a fresh transport) surface the protocol failure. - if (fromPool && parseResult.Error == HttpError.UnexpectedEof) - { - return TransportSendOutcome.Unused(); - } - - await SafeDisposeAsync(transport).ConfigureAwait(false); - return TransportSendOutcome.Used( - Result.Err(MapParseError(parseResult.Error))); - } - - var response = parseResult.Value; - response.Security = new ConnectionSecurity( - response.HttpVersion, url.IsHttps, transport.PeerCertificate); - - StoreResponseCookies(response, url); - - // Decide pool fate. We can only safely reuse the transport if: - // - both sides agreed to keep-alive (RFC 9112 §9.3), AND - // - the body had a definite framing so we know we drained - // exactly to the end (no over-read into the next response). - if (H1ResponseParser.IndicatesKeepAlive(response) - && H1ResponseParser.HasDefiniteBodyFraming(response) - && transport.IsOpen - && !_disposed) - { - await _pool.ReleaseAsync(transport).ConfigureAwait(false); - } - else + foreach (var value in header.Value) { - await SafeDisposeAsync(transport).ConfigureAwait(false); + headers.Add(header.Key, value); } - - return TransportSendOutcome.Used(Result.Ok(response)); } - catch (OperationCanceledException) + foreach (var header in message.Content.Headers.NonValidated) { - await SafeDisposeAsync(transport).ConfigureAwait(false); - // Cancellation includes our internal request-timeout CTS; let the - // caller distinguish via its own token. - throw; + foreach (var value in header.Value) + { + headers.Add(header.Key, value); + } } - catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException) + + var protocol = VersionString(message.Version); + var response = new HttpResponse( + protocol, + (int)message.StatusCode, + message.ReasonPhrase ?? string.Empty, + headers, + body) { - // If this was a pooled connection on its first byte, treat the - // failure as "socket died while idle" and let the caller retry. - // We can't reliably tell whether anything was actually written; - // for idempotent GETs (the only verb the engine uses today) a - // re-dial is safe. For non-GETs the caller surfaces this as a - // transport failure when the retry has nothing to fall back to. - // - // Sockets propagate ECONNRESET as raw SocketException (not wrapped - // in IOException), so we accept both. - return TransportSendOutcome.Unused(); - } + Security = new ConnectionSecurity( + protocol, + url.IsHttps, + url.IsHttps ? _connector.CertificateFor(url.Host!, port) : null), + }; + return response; } - /// - /// Send a request over a multiplexed HTTP/2 connection. Returns null when a - /// connection turned out to be unusable (GOAWAY / - /// closed) before the request was processed, signalling the caller to retry - /// on a fresh dial. For a freshly-dialled connection the result — success or - /// failure — is always returned. - /// - private async Task?> SendOverH2Async( - H2Connection conn, HttpRequest request, StarlingUrl url, bool reused, CancellationToken ct) + private static string VersionString(Version version) => version switch { - ApplyRequestCookies(request, url); - Activity.Current?.SetTag("connection.reused", reused); - Activity.Current?.SetTag("network.protocol.version", "2"); - if (reused) - { - StarlingTelemetry.Counter("net.http.connection_reused", 1); - } - - Result result; - using (var span = StarlingTelemetry.Span("net", "h2_request")) - { - StarlingTelemetry.Counter("net.h2.requests_sent", 1); - result = await conn.SendAsync(request, url, ct).ConfigureAwait(false); - } - - if (reused && result.IsErr && result.Error == NetworkError.TransportFailure) - { - return null; - } + { Major: 1, Minor: 0 } => "HTTP/1.0", + { Major: 1 } => "HTTP/1.1", + { Major: 2 } => "HTTP/2", + { Major: 3 } => "HTTP/3", + _ => $"HTTP/{version.Major}.{version.Minor}", + }; - if (result.IsOk) - { - result.Value.Security = new ConnectionSecurity( - result.Value.HttpVersion, url.IsHttps, conn.PeerCertificate); - StoreResponseCookies(result.Value, url); - } - return result; + private void RecordError(NetworkError error) + { + var message = error.ToString(); + StarlingTelemetry.Counter("net.http.failures", 1); + Activity.Current?.SetStatus(ActivityStatusCode.Error, message); + StarlingHttpClientLog.RequestFailed(_log, message); } + private static NetworkError MapException(HttpRequestException ex) => ex.HttpRequestError switch + { + HttpRequestError.NameResolutionError => NetworkError.DnsFailure, + HttpRequestError.ConnectionError => NetworkError.ConnectFailed, + HttpRequestError.SecureConnectionError => + ex.InnerException is System.Security.Authentication.AuthenticationException + ? NetworkError.TlsCertificateRejected + : NetworkError.TlsHandshakeFailed, + HttpRequestError.HttpProtocolError => NetworkError.ProtocolError, + HttpRequestError.VersionNegotiationError => NetworkError.ProtocolError, + _ => NetworkError.TransportFailure, + }; + /// /// Inject the cookie jar's view of the world into the request, but only when /// the caller hasn't already supplied a Cookie header. @@ -530,12 +289,6 @@ private void StoreResponseCookies(HttpResponse response, StarlingUrl url) } } - private async ValueTask SafeDisposeAsync(IHttpTransport transport) - { - try { await transport.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { StarlingHttpClientLog.SafeDisposeFailed(_log, ex); /* a half-broken socket may throw on shutdown; we don't care */ } - } - public void Dispose() { if (_disposed) @@ -544,31 +297,7 @@ public void Dispose() } _disposed = true; - // Synchronously dispose the pool and any HTTP/2 connections. Both only - // await socket teardowns, which are non-blocking; .GetAwaiter() - // .GetResult() is fine on the disposal path. - _h2.DisposeAsync().AsTask().GetAwaiter().GetResult(); - _pool.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - - private static NetworkError MapParseError(HttpError error) => error switch - { - HttpError.UnexpectedEof => NetworkError.TransportFailure, - HttpError.TransportFailure => NetworkError.TransportFailure, - HttpError.HeadersTooLarge => NetworkError.ProtocolError, - HttpError.BodyTooLarge => NetworkError.ProtocolError, - HttpError.BadStatusLine => NetworkError.ProtocolError, - HttpError.BadHeader => NetworkError.ProtocolError, - HttpError.BadChunkedFraming => NetworkError.ProtocolError, - HttpError.UnsupportedEncoding => NetworkError.ProtocolError, - HttpError.DecodeFailed => NetworkError.ProtocolError, - _ => NetworkError.ProtocolError, - }; - - private readonly record struct TransportSendOutcome(bool UsedTransport, Result Result) - { - public static TransportSendOutcome Used(Result r) => new(true, r); - public static TransportSendOutcome Unused() => new(false, default); + _http.Dispose(); } } @@ -579,8 +308,7 @@ public sealed class StarlingHttpClientOptions /// (notably google.com) sniff this and serve a degraded, JS-free page to any /// UA they don't recognise as a modern browser, so we present as a current /// Chrome on macOS. Injected by - /// when the request doesn't already carry a User-Agent, which is why it - /// drives both the HTTP/1.1 and HTTP/2 paths from a single source of truth. + /// when the request doesn't already carry a User-Agent. /// public const string DefaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " @@ -590,22 +318,11 @@ public sealed class StarlingHttpClientOptions public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30); public string UserAgent { get; init; } = DefaultUserAgent; public IReadOnlyList AlpnProtocols { get; init; } = ["h2", "http/1.1"]; - public DnsResolver? DnsResolver { get; init; } public CookieJar? CookieJar { get; init; } - public H1RequestWriter RequestWriter { get; init; } = new(); - public H1ResponseParser ResponseParser { get; init; } = new(); - - /// - /// Optional injected connection pool. When null, the client owns a - /// freshly-constructed pool with default sizing (6 idle per origin, - /// 60s idle timeout). - /// - public ConnectionPool? ConnectionPool { get; init; } /// /// Optional logger factory. When set, the client emits per-request log - /// messages and passes the factory through to HTTP/2 connections. - /// Tracing spans and metrics are always emitted via + /// messages. Tracing spans and metrics are always emitted via /// regardless. /// Defaults to . /// diff --git a/src/Starling.Net/Tcp/ITcpConnection.cs b/src/Starling.Net/Tcp/ITcpConnection.cs deleted file mode 100644 index 25babbe4..00000000 --- a/src/Starling.Net/Tcp/ITcpConnection.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Starling.Net.Tcp; - -/// -/// Async, byte-oriented TCP connection. Closed via . -/// -/// -/// Read returns 0 to signal a clean half-close from the peer (matches -/// 's -/// semantics). Write may return fewer bytes than requested only when the -/// underlying socket is shutting down; otherwise it loops internally. -/// -public interface ITcpConnection : IAsyncDisposable -{ - /// Remote endpoint we connected to (as written by the dialer). - TcpEndpoint Endpoint { get; } - - /// True until either side closes the connection. - bool IsOpen { get; } - - ValueTask ReadAsync(Memory buffer, CancellationToken ct); - ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken ct); - ValueTask ShutdownAsync(CancellationToken ct); -} diff --git a/src/Starling.Net/Tcp/SocketTcpConnection.cs b/src/Starling.Net/Tcp/SocketTcpConnection.cs deleted file mode 100644 index 44d661e4..00000000 --- a/src/Starling.Net/Tcp/SocketTcpConnection.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Net.Sockets; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Starling.Net.Tcp; - -internal static partial class SocketTcpConnectionLog -{ - [LoggerMessage(Level = LogLevel.Debug, Message = "socket shutdown threw; peer may already be gone")] - public static partial void ShutdownFailed(ILogger logger, Exception ex); -} - -/// -/// implementation backed by a real -/// . Pure managed per Rule 0. -/// -internal sealed class SocketTcpConnection(Socket socket, TcpEndpoint endpoint, ILogger? log = null) - : ITcpConnection -{ - private readonly Socket _socket = socket ?? throw new ArgumentNullException(nameof(socket)); - private readonly ILogger _log = log ?? NullLogger.Instance; - private bool _open = true; - - public TcpEndpoint Endpoint { get; } = endpoint; - - public bool IsOpen => _open && _socket.Connected; - - public async ValueTask ReadAsync(Memory buffer, CancellationToken ct) - { - if (!_open) - { - return 0; - } - - var n = await _socket.ReceiveAsync(buffer, SocketFlags.None, ct) - .ConfigureAwait(false); - // A zero-length read request always completes with 0 bytes — that is - // the documented "poll for readability" idiom, which SslStream issues - // to await data without pinning a buffer. Only a 0-byte result for a - // *non-empty* request is a peer half-close. Conflating the two marks - // the connection dead on SslStream's first zero-byte read and breaks - // the TLS handshake with a spurious EOF. - if (n == 0 && !buffer.IsEmpty) - { - _open = false; // peer closed - } - - return n; - } - - public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken ct) - { - if (!_open) - { - throw new InvalidOperationException("connection is closed"); - } - - var sent = 0; - while (sent < data.Length) - { - var n = await _socket.SendAsync(data[sent..], SocketFlags.None, ct) - .ConfigureAwait(false); - if (n == 0) { _open = false; break; } - sent += n; - } - } - - public ValueTask ShutdownAsync(CancellationToken ct) - { - if (!_open) - { - return ValueTask.CompletedTask; - } - - _open = false; - try { _socket.Shutdown(SocketShutdown.Both); } - catch (SocketException ex) { SocketTcpConnectionLog.ShutdownFailed(_log, ex); } - _ = ct; - return ValueTask.CompletedTask; - } - - public async ValueTask DisposeAsync() - { - await ShutdownAsync(CancellationToken.None); - _socket.Dispose(); - } -} diff --git a/src/Starling.Net/Tcp/TcpDialer.cs b/src/Starling.Net/Tcp/TcpDialer.cs deleted file mode 100644 index f0f6ad84..00000000 --- a/src/Starling.Net/Tcp/TcpDialer.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using Starling.Common; -using Starling.Net.Dns; - -namespace Starling.Net.Tcp; - -/// -/// Opens TCP connections by hostname, going through the Starling DNS -/// resolver. Returns an on success. -/// -/// -/// Tries each resolved address in order until one connects or all fail -/// (Happy Eyeballs sequencing is not implemented yet). The connect attempt -/// itself is bounded by . Cancellation tokens -/// passed in by the caller compose with that timeout. -/// -public sealed class TcpDialer -{ - private readonly DnsResolver _dns; - - public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); - - public TcpDialer(DnsResolver dnsResolver) - { - _dns = dnsResolver ?? throw new ArgumentNullException(nameof(dnsResolver)); - } - - public async Task> DialAsync( - TcpEndpoint endpoint, CancellationToken ct = default) - { - var dnsResult = await _dns.ResolveAsync(endpoint.Hostname, ct).ConfigureAwait(false); - if (dnsResult.IsErr) - { - return Result.Err(TcpError.DnsFailed); - } - - Exception? last = null; - foreach (var ip in dnsResult.Value.Addresses) - { - var family = ip.AddressFamily; - var socket = new Socket(family, SocketType.Stream, ProtocolType.Tcp); - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(ConnectTimeout); - await socket.ConnectAsync(new IPEndPoint(ip, endpoint.Port), cts.Token) - .ConfigureAwait(false); - return Result.Ok( - new SocketTcpConnection(socket, endpoint)); - } - catch (Exception ex) - { - last = ex; - socket.Dispose(); - } - } - _ = last; - return Result.Err(TcpError.ConnectFailed); - } - - /// - /// Connect directly to an already-resolved . - /// Bypasses DNS — useful for tests against a local listener. - /// - public async Task> DialDirectAsync( - IPEndPoint endpoint, TcpEndpoint label, CancellationToken ct = default) - { - var socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(ConnectTimeout); - await socket.ConnectAsync(endpoint, cts.Token).ConfigureAwait(false); - return Result.Ok( - new SocketTcpConnection(socket, label)); - } - catch - { - socket.Dispose(); - return Result.Err(TcpError.ConnectFailed); - } - } -} - -public enum TcpError -{ - DnsFailed, - ConnectFailed, - Timeout, -} diff --git a/src/Starling.Net/Tcp/TcpEndpoint.cs b/src/Starling.Net/Tcp/TcpEndpoint.cs deleted file mode 100644 index bf1aa7dd..00000000 --- a/src/Starling.Net/Tcp/TcpEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Starling.Net.Tcp; - -/// -/// Identifies a TCP destination by its DNS-level and -/// . The hostname is what we'll resolve; the resulting -/// IP+port goes into the kernel. -/// -public readonly record struct TcpEndpoint(string Hostname, int Port) -{ - public override string ToString() => $"{Hostname}:{Port}"; - - public static TcpEndpoint For(string hostname, int port) - { - if (string.IsNullOrWhiteSpace(hostname)) - { - throw new ArgumentException("Hostname required.", nameof(hostname)); - } - - if (port is < 1 or > 65535) - { - throw new ArgumentOutOfRangeException(nameof(port), port, "Port must be 1..65535."); - } - - return new TcpEndpoint(hostname, port); - } -} diff --git a/src/Starling.Net/Tls/BcDuplexTlsStream.cs b/src/Starling.Net/Tls/BcDuplexTlsStream.cs deleted file mode 100644 index e443dec1..00000000 --- a/src/Starling.Net/Tls/BcDuplexTlsStream.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Buffers; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Org.BouncyCastle.Tls; - -namespace Starling.Net.Tls; - -internal static partial class BcDuplexTlsStreamLog -{ - [LoggerMessage(Level = LogLevel.Debug, Message = "close_notify flush failed; peer may already be gone")] - public static partial void CloseNotifyFailed(ILogger logger, Exception ex); -} - -/// -/// A full-duplex over BouncyCastle's non-blocking -/// . BouncyCastle's blocking stream serializes -/// reads and writes (a read blocked waiting for the peer also blocks writes), -/// which deadlocks HTTP/2 — where one reader loop and concurrent request writers -/// must share the connection. This wrapper instead feeds ciphertext in/out of -/// the protocol's in-memory buffers, holding a lock only across the (instant) -/// state transitions and performing all socket I/O outside it. That lets a -/// blocked socket read coexist with an in-flight write. -/// -internal sealed class BcDuplexTlsStream : Stream -{ - // A TLS record is at most 2^14 + overhead; this comfortably holds one read. - private const int CipherBufferSize = 18 * 1024; - - private readonly TlsClientProtocol _protocol; - private readonly Stream _transport; - private readonly object _tlsGate = new(); - private readonly SemaphoreSlim _socketWrite = new(1, 1); - private readonly byte[] _cipherReadBuffer = new byte[CipherBufferSize]; - private readonly ILogger _log; - private bool _disposed; - - private BcDuplexTlsStream(TlsClientProtocol protocol, Stream transport, ILogger log) - { - _protocol = protocol; - _transport = transport; - _log = log; - } - - /// - /// Drive the (non-blocking) TLS handshake to completion over - /// and return the established duplex stream. - /// The handshake is single-threaded, so no locking is needed here. - /// - public static async Task HandshakeAsync( - TlsClientProtocol protocol, TlsClient client, Stream transport, CancellationToken ct, - ILogger? log = null) - { - log ??= NullLogger.Instance; - protocol.Connect(client); - await PumpOutputAsync(protocol, transport, ct).ConfigureAwait(false); // send ClientHello - - var cipher = new byte[CipherBufferSize]; - while (protocol.IsHandshaking) - { - var n = await transport.ReadAsync(cipher, ct).ConfigureAwait(false); - if (n == 0) - { - throw new EndOfStreamException("peer closed during TLS handshake"); - } - - protocol.OfferInput(cipher, 0, n); - await PumpOutputAsync(protocol, transport, ct).ConfigureAwait(false); // e.g. client Finished - } - - return new BcDuplexTlsStream(protocol, transport, log); - } - - private static async Task PumpOutputAsync(TlsClientProtocol protocol, Stream transport, CancellationToken ct) - { - var available = protocol.GetAvailableOutputBytes(); - if (available == 0) - { - return; - } - - var buf = new byte[available]; - var read = protocol.ReadOutput(buf, 0, available); - await transport.WriteAsync(buf.AsMemory(0, read), ct).ConfigureAwait(false); - await transport.FlushAsync(ct).ConfigureAwait(false); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - if (buffer.IsEmpty) - { - return 0; - } - - while (true) - { - // 1. Hand back any already-decrypted application data. - byte[]? appChunk = null; - var got = 0; - lock (_tlsGate) - { - var available = _protocol.GetAvailableInputBytes(); - if (available > 0) - { - got = Math.Min(available, buffer.Length); - appChunk = ArrayPool.Shared.Rent(got); - _protocol.ReadInput(appChunk, 0, got); - } - } - if (appChunk is not null) - { - appChunk.AsSpan(0, got).CopyTo(buffer.Span); - ArrayPool.Shared.Return(appChunk); - return got; - } - - // 2. Otherwise pull ciphertext off the socket (outside the lock, so a - // concurrent write isn't blocked) and feed it to the protocol. - var n = await _transport.ReadAsync(_cipherReadBuffer, ct).ConfigureAwait(false); - if (n == 0) - { - return 0; // clean EOF - } - - byte[]? outChunk = null; - var outLen = 0; - lock (_tlsGate) - { - _protocol.OfferInput(_cipherReadBuffer, 0, n); - // Processing input can produce output (e.g. a post-handshake - // KeyUpdate response); flush it back to the peer. - var pending = _protocol.GetAvailableOutputBytes(); - if (pending > 0) - { - outChunk = new byte[pending]; - outLen = _protocol.ReadOutput(outChunk, 0, pending); - } - } - if (outChunk is not null) - { - await SocketWriteAsync(outChunk.AsMemory(0, outLen), ct).ConfigureAwait(false); - } - } - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - if (buffer.IsEmpty) - { - return; - } - - byte[] cipher; - int cipherLen; - lock (_tlsGate) - { - _protocol.WriteApplicationData(buffer.ToArray(), 0, buffer.Length); - var pending = _protocol.GetAvailableOutputBytes(); - cipher = new byte[pending]; - cipherLen = _protocol.ReadOutput(cipher, 0, pending); - } - await SocketWriteAsync(cipher.AsMemory(0, cipherLen), ct).ConfigureAwait(false); - } - - private async Task SocketWriteAsync(ReadOnlyMemory data, CancellationToken ct) - { - // Serialize socket writes: both the write path and read-path-generated - // output (KeyUpdate, alerts) can reach here concurrently. - await _socketWrite.WaitAsync(ct).ConfigureAwait(false); - try - { - await _transport.WriteAsync(data, ct).ConfigureAwait(false); - await _transport.FlushAsync(ct).ConfigureAwait(false); - } - finally { _socketWrite.Release(); } - } - - public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask; - public override void Flush() { } - - public override int Read(byte[] buffer, int offset, int count) => - ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); - - public override void Write(byte[] buffer, int offset, int count) => - WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); - - public override bool CanRead => !_disposed; - public override bool CanWrite => !_disposed; - public override bool CanSeek => false; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - 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) - { - return; - } - - _disposed = true; - if (disposing) - { - // Best-effort close_notify, then tear down the socket. - try - { - byte[]? outChunk = null; - var outLen = 0; - lock (_tlsGate) - { - _protocol.Close(); - var pending = _protocol.GetAvailableOutputBytes(); - if (pending > 0) - { - outChunk = new byte[pending]; - outLen = _protocol.ReadOutput(outChunk, 0, pending); - } - } - if (outChunk is not null) - { - _socketWrite.Wait(); - try { _transport.Write(outChunk, 0, outLen); } - finally { _socketWrite.Release(); } - } - } - catch (Exception ex) { BcDuplexTlsStreamLog.CloseNotifyFailed(_log, ex); /* the peer may already be gone */ } - - _socketWrite.Dispose(); - _transport.Dispose(); - } - base.Dispose(disposing); - } -} diff --git a/src/Starling.Net/Tls/BcTlsTransport.cs b/src/Starling.Net/Tls/BcTlsTransport.cs deleted file mode 100644 index 6d9a40b7..00000000 --- a/src/Starling.Net/Tls/BcTlsTransport.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Starling.Common; -using Starling.Net.Tcp; -using Org.BouncyCastle.Security; -using Org.BouncyCastle.Tls; -using Org.BouncyCastle.Tls.Crypto.Impl.BC; - -namespace Starling.Net.Tls; - -/// -/// Pure-managed TLS transport built on BouncyCastle's TLS 1.3 implementation. -/// Drives the protocol in non-blocking mode behind -/// so reads and writes can proceed concurrently — a hard requirement for HTTP/2, -/// which multiplexes a single connection between a reader loop and request -/// writers. -/// -public sealed class BcTlsTransport : ITlsTransport -{ - private readonly TlsClientProtocol _protocol; - private readonly BcDuplexTlsStream _stream; - private bool _disposed; - - private BcTlsTransport( - TlsClientProtocol protocol, - BcDuplexTlsStream stream, - string? negotiatedApplicationProtocol, - CertificateSummary? peerCertificate) - { - _protocol = protocol; - _stream = stream; - NegotiatedApplicationProtocol = negotiatedApplicationProtocol; - PeerCertificate = peerCertificate; - } - - public Stream Stream => _stream; - public string? NegotiatedApplicationProtocol { get; } - public CertificateSummary? PeerCertificate { get; } - - public static async Task> ConnectAsync( - ITcpConnection tcpConnection, - TlsClientOptions options, - CancellationToken ct = default) - { - if (tcpConnection is null) - { - throw new ArgumentNullException(nameof(tcpConnection)); - } - - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - if (string.IsNullOrWhiteSpace(options.ServerName) || options.ApplicationProtocols.Count == 0) - { - return Result.Err(TlsError.InvalidOptions); - } - - var tcpStream = new TcpConnectionStream(tcpConnection); - var client = new StarlingTlsClient( - new BcTlsCrypto(new SecureRandom()), - options, - RootCertificates.SystemTrust); - var protocol = new TlsClientProtocol(); // non-blocking mode - - try - { - var stream = await BcDuplexTlsStream.HandshakeAsync(protocol, client, tcpStream, ct) - .ConfigureAwait(false); - return Result.Ok( - new BcTlsTransport(protocol, stream, client.NegotiatedApplicationProtocol, client.PeerCertificate)); - } - catch (TlsFatalAlert alert) when (alert.AlertDescription is AlertDescription.bad_certificate - or AlertDescription.certificate_expired - or AlertDescription.certificate_revoked - or AlertDescription.certificate_unknown - or AlertDescription.unknown_ca) - { - protocol.Close(); - return Result.Err(TlsError.CertificateRejected); - } - catch - { - protocol.Close(); - return Result.Err(TlsError.HandshakeFailed); - } - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - // Disposing the duplex stream sends close_notify (best-effort) and tears - // down the wrapped TCP connection. - _stream.Dispose(); - } -} diff --git a/src/Starling.Net/Tls/CertificateVerifier.cs b/src/Starling.Net/Tls/CertificateVerifier.cs index b0b8a78e..3bb8e9c3 100644 --- a/src/Starling.Net/Tls/CertificateVerifier.cs +++ b/src/Starling.Net/Tls/CertificateVerifier.cs @@ -1,236 +1,95 @@ -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Pkix; -using Org.BouncyCastle.Tls; -using Org.BouncyCastle.Utilities.Collections; -using Org.BouncyCastle.X509; -using Org.BouncyCastle.X509.Store; +using System.Security.Cryptography.X509Certificates; namespace Starling.Net.Tls; +/// +/// Server-certificate verification against the bundled trust anchors. Chains the +/// presented leaf to a root in (the OS trust store +/// is not consulted unless folded in via ), +/// enforces the validity window and path constraints via , +/// and matches the requested host against the leaf's subject alternative names. +/// Starling fails closed: a rejected chain aborts the connection. +/// public static class CertificateVerifier { public static bool Verify( - Certificate certificate, + X509Certificate2 leaf, + X509Certificate2Collection? presentedIntermediates, string hostname, RootCertificates roots, - DateTimeOffset? validationTime = null, - RevocationSet? revocations = null) + DateTimeOffset? validationTime = null) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - if (roots is null) - { - throw new ArgumentNullException(nameof(roots)); - } + ArgumentNullException.ThrowIfNull(leaf); + ArgumentNullException.ThrowIfNull(roots); if (string.IsNullOrWhiteSpace(hostname)) { return false; } - var chain = DecodeChain(certificate); - if (chain.Count == 0) + // RFC 6125 host match. No fall-through to the legacy CN field. + if (!leaf.MatchesHostname(hostname, allowWildcards: true, allowCommonName: false)) { return false; } - // PKIX path validation does not match the hostname, so that stays ours. - // Everything else — path building to a trusted anchor, signatures, - // validity windows, basic constraints, key usage, name constraints — is - // delegated to BouncyCastle's RFC 5280 validator below. - if (!CertificateHostNameMatcher.Matches(chain[0], hostname)) + using var chain = new X509Chain(); + var policy = chain.ChainPolicy; + // Chain to our bundled anchors only — the OS trust store is not consulted. + policy.TrustMode = X509ChainTrustMode.CustomRootTrust; + policy.CustomTrustStore.AddRange(roots.Certificates); + // Live OCSP/CRL would add blocking network I/O on the handshake path. + policy.RevocationMode = X509RevocationMode.NoCheck; + policy.VerificationFlags = X509VerificationFlags.NoFlag; + if (validationTime is { } when) { - return false; - } - - var path = BuildTrustedPath(chain, roots.Certificates, validationTime); - if (path is null) - { - return false; + policy.VerificationTime = when.UtcDateTime; } - - // Local CRLSet-style blocklist check over the validated path. Empty by - // default, so this is a no-op until a revocation feed is loaded. - var revocationSet = revocations ?? RevocationSet.Empty; - if (!revocationSet.IsEmpty && PathContainsRevokedCert(path, revocationSet)) + if (presentedIntermediates is { Count: > 0 }) { - return false; + policy.ExtraStore.AddRange(presentedIntermediates); } - return true; - } - - // Build and validate a path from the leaf (chain[0]) to any trusted root, - // using the presented certs as the pool of candidate intermediates. The - // builder picks the shortest valid path, so extra cross-sign certs the - // server appends above the real anchor (e.g. Google's GTS Root R1 trailed by - // a legacy GlobalSign root we don't bundle) no longer cause a rejection. - // Returns null when no valid path exists. - private static PkixCertPathBuilderResult? BuildTrustedPath( - List chain, - IReadOnlyList roots, - DateTimeOffset? validationTime) - { - var anchors = new HashSet(); - foreach (var root in roots) - { - anchors.Add(new TrustAnchor(root, null)); - } - - var target = new X509CertStoreSelector { Certificate = chain[0] }; - var parameters = new PkixBuilderParameters(anchors, target) - { - // Live OCSP/CRL would add blocking network I/O on the handshake path. - // Revocation is handled out of band by the local blocklist below. - IsRevocationEnabled = false, - Date = (validationTime ?? DateTimeOffset.UtcNow).UtcDateTime, - }; - parameters.AddStoreCert(CollectionUtilities.CreateStore(chain)); - - try - { - return new PkixCertPathBuilder().Build(parameters); - } - catch (PkixCertPathBuilderException) - { - return null; - } - } - - // Walk the built path from leaf up to the trust anchor, checking each cert - // against the blocklist. The issuer of each cert is the next one up; the - // anchor is self-issued. - private static bool PathContainsRevokedCert(PkixCertPathBuilderResult path, RevocationSet revocations) - { - var ordered = new List(path.CertPath.Certificates) { path.TrustAnchor.TrustedCert }; - for (var i = 0; i < ordered.Count; i++) - { - var issuer = i + 1 < ordered.Count ? ordered[i + 1] : ordered[i]; - if (revocations.IsRevoked(ordered[i], issuer)) - { - return true; - } - } - - return false; + return chain.Build(leaf); } /// - /// Build a display summary of the leaf (end-entity) certificate. Returns - /// null when the chain is empty. Intended for UI surfaces (the lock popover) - /// after has already accepted the chain. + /// Build a display summary of the leaf certificate for the shell lock UI. + /// Intended to be called after has accepted the chain. /// - public static CertificateSummary? Summarize(Certificate certificate) + public static CertificateSummary Summarize(X509Certificate2 leaf) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - var chain = DecodeChain(certificate); - if (chain.Count == 0) - { - return null; - } - - var leaf = chain[0]; + ArgumentNullException.ThrowIfNull(leaf); return new CertificateSummary( - FriendlyName(leaf.SubjectDN), - FriendlyName(leaf.IssuerDN), - new DateTimeOffset(leaf.NotBefore.ToUniversalTime(), TimeSpan.Zero), - new DateTimeOffset(leaf.NotAfter.ToUniversalTime(), TimeSpan.Zero)); + FriendlyName(leaf.SubjectName, leaf.Subject), + FriendlyName(leaf.IssuerName, leaf.Issuer), + leaf.NotBefore.ToUniversalTime(), + leaf.NotAfter.ToUniversalTime()); } // Prefer the common name; fall back to the organisation, then the full DN. - private static string FriendlyName(X509Name dn) + private static string FriendlyName(X500DistinguishedName dn, string fullDn) { - foreach (var oid in new[] { X509Name.CN, X509Name.O }) + foreach (var oid in new[] { "CN", "O" }) { - var values = dn.GetValueList(oid); - if (values.Count > 0 && values[0] is string s && !string.IsNullOrWhiteSpace(s)) + var value = FindRdn(dn, oid); + if (!string.IsNullOrWhiteSpace(value)) { - return s; + return value; } } - return dn.ToString(); + return fullDn; } - private static List DecodeChain(Certificate certificate) + private static string? FindRdn(X500DistinguishedName dn, string oidFriendlyName) { - var parser = new X509CertificateParser(); - return certificate.GetCertificateList() - .Select(tlsCertificate => parser.ReadCertificate(tlsCertificate.GetEncoded())) - .ToList(); - } -} - -public static class CertificateHostNameMatcher -{ - public static bool Matches(X509Certificate certificate, string hostname) - { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - if (string.IsNullOrWhiteSpace(hostname)) + foreach (var rdn in dn.EnumerateRelativeDistinguishedNames()) { - return false; - } - - var normalizedHost = hostname.Trim().TrimEnd('.').ToLowerInvariant(); - var names = certificate.GetSubjectAlternativeNames(); - if (names is null || names.Count == 0) - { - return false; - } - - foreach (var name in names) - { - if (name.Count < 2 || name[0] is not int { } type || type != GeneralName.DnsName) - { - continue; - } - - if (name[1] is string dnsName && MatchDnsName(dnsName, normalizedHost)) + if (string.Equals(rdn.GetSingleElementType().FriendlyName, oidFriendlyName, StringComparison.OrdinalIgnoreCase)) { - return true; + return rdn.GetSingleElementValue(); } } - - return false; - } - - public static bool MatchDnsName(string pattern, string hostname) - { - var normalizedPattern = pattern.Trim().TrimEnd('.').ToLowerInvariant(); - var normalizedHost = hostname.Trim().TrimEnd('.').ToLowerInvariant(); - if (normalizedPattern.Length == 0 || normalizedHost.Length == 0) - { - return false; - } - - if (!normalizedPattern.Contains('*', StringComparison.Ordinal)) - { - return normalizedPattern == normalizedHost; - } - - if (!normalizedPattern.StartsWith("*.", StringComparison.Ordinal) - || normalizedPattern.IndexOf('*', 1) >= 0) - { - return false; - } - - var suffix = normalizedPattern[1..]; - if (!normalizedHost.EndsWith(suffix, StringComparison.Ordinal)) - { - return false; - } - - var unmatched = normalizedHost[..^suffix.Length]; - return unmatched.Length > 0 && !unmatched.Contains('.', StringComparison.Ordinal); + return null; } } diff --git a/src/Starling.Net/Tls/ITlsTransport.cs b/src/Starling.Net/Tls/ITlsTransport.cs deleted file mode 100644 index 0d5ba00d..00000000 --- a/src/Starling.Net/Tls/ITlsTransport.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Starling.Net.Tls; - -/// -/// TLS-protected byte stream established over a Starling TCP connection. -/// -public interface ITlsTransport : IDisposable -{ - Stream Stream { get; } - string? NegotiatedApplicationProtocol { get; } - - /// The verified leaf certificate presented by the peer, or null. - CertificateSummary? PeerCertificate { get; } -} diff --git a/src/Starling.Net/Tls/RevocationSet.cs b/src/Starling.Net/Tls/RevocationSet.cs deleted file mode 100644 index 23f6bbdd..00000000 --- a/src/Starling.Net/Tls/RevocationSet.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Security.Cryptography; -using Org.BouncyCastle.X509; - -namespace Starling.Net.Tls; - -/// -/// A local, CRLSet-style revocation blocklist consulted during certificate -/// validation. It holds two kinds of entries, both matched in memory with no -/// network on the handshake path: -/// -/// -/// Blocked SPKIs — the SHA-256 of a Subject Public Key Info. Any -/// cert carrying that key is rejected wherever it appears in the path. Used to -/// distrust a whole compromised or misbehaving CA key. -/// Revoked serials — an (issuer SPKI SHA-256, serial) pair, the -/// standard way to revoke one leaf or intermediate without distrusting its -/// issuer. -/// -/// -/// The set is empty by default, so it changes no behaviour until a data source -/// populates it. Refreshing that data from an out-of-band feed is a separate, -/// later piece — this type is only the consumer. -/// -public sealed class RevocationSet -{ - private readonly HashSet _blockedSpki; - private readonly HashSet<(string IssuerSpki, string Serial)> _revokedSerials; - - private RevocationSet( - HashSet blockedSpki, - HashSet<(string, string)> revokedSerials) - { - _blockedSpki = blockedSpki; - _revokedSerials = revokedSerials; - } - - /// A set with no entries — revokes nothing. - public static RevocationSet Empty { get; } = - new(new HashSet(), new HashSet<(string, string)>()); - - /// - /// The blocklist live connections consult. Loaded from an embedded resource - /// if one is present, otherwise . - /// - public static RevocationSet Default { get; } = LoadDefault(); - - public bool IsEmpty => _blockedSpki.Count == 0 && _revokedSerials.Count == 0; - - public static RevocationSet Create( - IEnumerable blockedSpki, - IEnumerable<(string IssuerSpki, string Serial)> revokedSerials) - { - if (blockedSpki is null) - { - throw new ArgumentNullException(nameof(blockedSpki)); - } - - if (revokedSerials is null) - { - throw new ArgumentNullException(nameof(revokedSerials)); - } - - return new RevocationSet( - blockedSpki.Select(Normalize).ToHashSet(), - revokedSerials.Select(e => (Normalize(e.IssuerSpki), Normalize(e.Serial))).ToHashSet()); - } - - /// - /// True when is revoked — either its key is - /// blocked outright, or its serial is revoked under . - /// - public bool IsRevoked(X509Certificate certificate, X509Certificate issuer) - { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - if (issuer is null) - { - throw new ArgumentNullException(nameof(issuer)); - } - - if (IsEmpty) - { - return false; - } - - if (_blockedSpki.Count > 0 && _blockedSpki.Contains(SpkiHash(certificate))) - { - return true; - } - - return _revokedSerials.Count > 0 - && _revokedSerials.Contains((SpkiHash(issuer), SerialHex(certificate))); - } - - /// SHA-256 of the cert's Subject Public Key Info, as upper-hex. - public static string SpkiHash(X509Certificate certificate) - { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - var spki = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory - .CreateSubjectPublicKeyInfo(certificate.GetPublicKey()) - .GetDerEncoded(); - return Convert.ToHexString(SHA256.HashData(spki)); - } - - /// The cert's serial number as upper-hex of its unsigned big-endian bytes. - public static string SerialHex(X509Certificate certificate) - { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - return Convert.ToHexString(certificate.SerialNumber.ToByteArrayUnsigned()); - } - - /// - /// Parse the blocklist text format. One directive per line, '#' starts a - /// comment, blank lines ignored: - /// - /// spki <sha256-hex-of-SPKI> - /// serial <sha256-hex-of-issuer-SPKI> <serial-hex> - /// - /// - public static RevocationSet FromText(Stream textStream) - { - if (textStream is null) - { - throw new ArgumentNullException(nameof(textStream)); - } - - var blockedSpki = new HashSet(); - var revokedSerials = new HashSet<(string, string)>(); - - using var reader = new StreamReader(textStream); - string? line; - while ((line = reader.ReadLine()) is not null) - { - var trimmed = line.Trim(); - if (trimmed.Length == 0 || trimmed[0] == '#') - { - continue; - } - - var parts = trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - switch (parts[0].ToLowerInvariant()) - { - case "spki" when parts.Length == 2: - blockedSpki.Add(Normalize(parts[1])); - break; - case "serial" when parts.Length == 3: - revokedSerials.Add((Normalize(parts[1]), Normalize(parts[2]))); - break; - default: - throw new InvalidDataException($"malformed revocation entry: {trimmed}"); - } - } - - return new RevocationSet(blockedSpki, revokedSerials); - } - - private static string Normalize(string hex) => - (hex ?? throw new ArgumentNullException(nameof(hex))) - .Trim() - .Replace(":", "", StringComparison.Ordinal) - .ToUpperInvariant(); - - private static RevocationSet LoadDefault() - { - const string resourceSuffix = ".Resources.Revocations.blocklist.txt"; - var assembly = typeof(RevocationSet).Assembly; - var resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(name => name.EndsWith(resourceSuffix, StringComparison.Ordinal)); - if (resourceName is null) - { - return Empty; - } - - using var stream = assembly.GetManifestResourceStream(resourceName); - return stream is null ? Empty : FromText(stream); - } -} diff --git a/src/Starling.Net/Tls/RootCertificates.cs b/src/Starling.Net/Tls/RootCertificates.cs index fa152693..7809091c 100644 --- a/src/Starling.Net/Tls/RootCertificates.cs +++ b/src/Starling.Net/Tls/RootCertificates.cs @@ -1,13 +1,18 @@ -using Org.BouncyCastle.X509; +using System.Security.Cryptography.X509Certificates; namespace Starling.Net.Tls; +/// +/// The trust anchors Starling chains server certificates to. Backed by the +/// embedded CCADB bundle so trust decisions are deterministic across machines, +/// independent of the OS trust store. +/// public sealed class RootCertificates { private const string ResourceSuffix = ".Resources.Roots.ccadb.pem"; - private readonly IReadOnlyList _certificates; + private readonly X509Certificate2Collection _certificates; - private RootCertificates(IReadOnlyList certificates) + private RootCertificates(X509Certificate2Collection certificates) { _certificates = certificates; } @@ -26,7 +31,11 @@ private RootCertificates(IReadOnlyList certificates) /// public static RootCertificates SystemTrust { get; } = BuildSystemTrust(); - public IReadOnlyList Certificates => _certificates; + /// + /// The trust anchors as a collection suitable for + /// . + /// + public X509Certificate2Collection Certificates => _certificates; public static RootCertificates FromPem(Stream pemStream) { @@ -35,9 +44,11 @@ public static RootCertificates FromPem(Stream pemStream) throw new ArgumentNullException(nameof(pemStream)); } - var parser = new X509CertificateParser(); - var certificates = parser.ReadCertificates(pemStream).ToArray(); - if (certificates.Length == 0) + using var reader = new StreamReader(pemStream); + var pem = reader.ReadToEnd(); + var certificates = new X509Certificate2Collection(); + certificates.ImportFromPem(pem); + if (certificates.Count == 0) { throw new InvalidDataException("root certificate bundle is empty"); } @@ -47,18 +58,20 @@ public static RootCertificates FromPem(Stream pemStream) private static RootCertificates BuildSystemTrust() { - var combined = new List(Default._certificates); - // Dedup by encoded bytes so a CA present in both the bundle and the OS - // store becomes a single trust anchor. - var seen = new HashSet(Default._certificates.Count); + var combined = new X509Certificate2Collection(); + combined.AddRange(Default._certificates); + + // Dedup by thumbprint so a CA present in both the bundle and the OS store + // becomes a single trust anchor. + var seen = new HashSet(Default._certificates.Count, StringComparer.Ordinal); foreach (var certificate in Default._certificates) { - seen.Add(Fingerprint(certificate)); + seen.Add(certificate.Thumbprint); } foreach (var certificate in SystemRootCertificates.Load()) { - if (seen.Add(Fingerprint(certificate))) + if (seen.Add(certificate.Thumbprint)) { combined.Add(certificate); } @@ -67,9 +80,6 @@ private static RootCertificates BuildSystemTrust() return new RootCertificates(combined); } - private static string Fingerprint(X509Certificate certificate) => - Convert.ToHexString(certificate.GetEncoded()); - private static RootCertificates LoadDefault() { var assembly = typeof(RootCertificates).Assembly; diff --git a/src/Starling.Net/Tls/StarlingTlsAuthentication.cs b/src/Starling.Net/Tls/StarlingTlsAuthentication.cs deleted file mode 100644 index 100b3911..00000000 --- a/src/Starling.Net/Tls/StarlingTlsAuthentication.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Org.BouncyCastle.Tls; - -namespace Starling.Net.Tls; - -internal sealed class StarlingTlsAuthentication : TlsAuthentication -{ - private readonly TlsClientOptions _options; - private readonly RootCertificates _roots; - private readonly RevocationSet _revocations; - private readonly Action _onVerified; - - public StarlingTlsAuthentication( - TlsClientOptions options, - RootCertificates roots, - RevocationSet revocations, - Action onVerified) - { - _options = options; - _roots = roots; - _revocations = revocations; - _onVerified = onVerified; - } - - public void NotifyServerCertificate(TlsServerCertificate serverCertificate) - { - if (!CertificateVerifier.Verify( - serverCertificate.Certificate, _options.ServerName, _roots, _options.ValidationTime, _revocations)) - { - throw new TlsFatalAlert(AlertDescription.bad_certificate, "server certificate validation failed"); - } - - // Capture the verified leaf for the UI lock popover. Only reached once - // the chain has validated against the bundled root store. - _onVerified(CertificateVerifier.Summarize(serverCertificate.Certificate)); - } - - public TlsCredentials? GetClientCredentials(CertificateRequest certificateRequest) => null; -} diff --git a/src/Starling.Net/Tls/StarlingTlsClient.cs b/src/Starling.Net/Tls/StarlingTlsClient.cs deleted file mode 100644 index b9bfd1df..00000000 --- a/src/Starling.Net/Tls/StarlingTlsClient.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text; -using Org.BouncyCastle.Tls; -using Org.BouncyCastle.Tls.Crypto; - -namespace Starling.Net.Tls; - -internal sealed class StarlingTlsClient : DefaultTlsClient -{ - private readonly TlsClientOptions _options; - private readonly RootCertificates _roots; - private readonly IList _protocolNames; - - public StarlingTlsClient(TlsCrypto crypto, TlsClientOptions options, RootCertificates roots) - : base(crypto) - { - _options = options; - _roots = roots; - _protocolNames = options.ApplicationProtocols - .Select(ProtocolName.AsUtf8Encoding) - .ToArray(); - } - - public string? NegotiatedApplicationProtocol { get; private set; } - - /// The verified leaf certificate, available after the handshake. - public CertificateSummary? PeerCertificate { get; private set; } - - public override TlsAuthentication GetAuthentication() => - new StarlingTlsAuthentication( - _options, _roots, RevocationSet.Default, cert => PeerCertificate = cert); - - public override IDictionary GetClientExtensions() - { - var extensions = TlsExtensionsUtilities.EnsureExtensionsInitialised(base.GetClientExtensions()); - return AddStarlingExtensions(extensions); - } - - internal IDictionary CreateClientExtensionsForTesting() => - AddStarlingExtensions(new Dictionary()); - - public override void ProcessServerExtensions(IDictionary serverExtensions) - { - base.ProcessServerExtensions(serverExtensions); - var protocol = TlsExtensionsUtilities.GetAlpnExtensionServer(serverExtensions); - NegotiatedApplicationProtocol = protocol?.GetUtf8Decoding(); - } - - protected override IList GetProtocolNames() => _protocolNames; - - protected override IList GetSniServerNames() => - [new ServerName(NameType.host_name, Encoding.ASCII.GetBytes(_options.ServerName))]; - - protected override ProtocolVersion[] GetSupportedVersions() => - [ProtocolVersion.TLSv13, ProtocolVersion.TLSv12]; - - protected override int[] GetSupportedCipherSuites() => - [ - CipherSuite.TLS_AES_128_GCM_SHA256, - CipherSuite.TLS_AES_256_GCM_SHA384, - CipherSuite.TLS_CHACHA20_POLY1305_SHA256, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - ]; - - private IDictionary AddStarlingExtensions(IDictionary extensions) - { - TlsExtensionsUtilities.AddServerNameExtensionClient(extensions, GetSniServerNames()); - TlsExtensionsUtilities.AddAlpnExtensionClient(extensions, _protocolNames); - return extensions; - } -} diff --git a/src/Starling.Net/Tls/SystemRootCertificates.cs b/src/Starling.Net/Tls/SystemRootCertificates.cs index d7d66984..a1ba6dd9 100644 --- a/src/Starling.Net/Tls/SystemRootCertificates.cs +++ b/src/Starling.Net/Tls/SystemRootCertificates.cs @@ -1,16 +1,11 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Org.BouncyCastle.X509; -using BcCertificate = Org.BouncyCastle.X509.X509Certificate; namespace Starling.Net.Tls; internal static partial class SystemRootCertificatesLog { - [LoggerMessage(Level = LogLevel.Debug, Message = "skipping unparseable certificate in {StoreLocation} Root store")] - public static partial void DroppedCert(ILogger logger, Exception ex, string storeLocation); - [LoggerMessage(Level = LogLevel.Debug, Message = "OS Root store unavailable for {StoreLocation}")] public static partial void StoreUnavailable(ILogger logger, Exception ex, string storeLocation); } @@ -27,11 +22,10 @@ internal static partial class SystemRootCertificatesLog /// internal static class SystemRootCertificates { - public static IReadOnlyList Load(ILogger? log = null) + public static IReadOnlyList Load(ILogger? log = null) { log ??= NullLogger.Instance; - var parser = new X509CertificateParser(); - var certificates = new List(); + var certificates = new List(); foreach (var location in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine }) { @@ -41,16 +35,7 @@ public static IReadOnlyList Load(ILogger? log = null) store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); foreach (var osCertificate in store.Certificates) { - try - { - certificates.Add(parser.ReadCertificate(osCertificate.RawData)); - } - catch (Exception ex) - { - // Skip any entry BouncyCastle can't parse; one bad cert - // must not poison the whole store. - SystemRootCertificatesLog.DroppedCert(log, ex, location.ToString()); - } + certificates.Add(osCertificate); } } catch (Exception ex) diff --git a/src/Starling.Net/Tls/TcpConnectionStream.cs b/src/Starling.Net/Tls/TcpConnectionStream.cs deleted file mode 100644 index ca57bb73..00000000 --- a/src/Starling.Net/Tls/TcpConnectionStream.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Starling.Net.Tcp; - -namespace Starling.Net.Tls; - -internal sealed class TcpConnectionStream : Stream -{ - private readonly ITcpConnection _connection; - - public TcpConnectionStream(ITcpConnection connection) - { - _connection = connection ?? throw new ArgumentNullException(nameof(connection)); - } - - public override bool CanRead => _connection.IsOpen; - public override bool CanSeek => false; - public override bool CanWrite => _connection.IsOpen; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) => - _connection.ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None) - .AsTask() - .GetAwaiter() - .GetResult(); - - public override void Write(byte[] buffer, int offset, int count) => - _connection.WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None) - .AsTask() - .GetAwaiter() - .GetResult(); - - public override async ValueTask ReadAsync( - Memory buffer, - CancellationToken cancellationToken = default) => - await _connection.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - - public override async ValueTask WriteAsync( - ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) => - await _connection.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - - 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 (disposing) - { - _connection.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - - base.Dispose(disposing); - } -} diff --git a/src/Starling.Net/Tls/TlsClientOptions.cs b/src/Starling.Net/Tls/TlsClientOptions.cs deleted file mode 100644 index f75a2c72..00000000 --- a/src/Starling.Net/Tls/TlsClientOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Starling.Net.Tls; - -public sealed record TlsClientOptions( - string ServerName, - IReadOnlyList ApplicationProtocols, - DateTimeOffset? ValidationTime = null) -{ - public static TlsClientOptions ForHttps(string serverName) => - new(serverName, ["h2", "http/1.1"]); -} diff --git a/src/Starling.Net/Tls/TlsError.cs b/src/Starling.Net/Tls/TlsError.cs deleted file mode 100644 index 32595658..00000000 --- a/src/Starling.Net/Tls/TlsError.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Starling.Net.Tls; - -public enum TlsError -{ - InvalidOptions, - HandshakeFailed, - CertificateRejected, -} diff --git a/tests/Starling.Engine.Tests/EngineHttpTests.cs b/tests/Starling.Engine.Tests/EngineHttpTests.cs index 2927d0fb..1ff020dd 100644 --- a/tests/Starling.Engine.Tests/EngineHttpTests.cs +++ b/tests/Starling.Engine.Tests/EngineHttpTests.cs @@ -5,7 +5,6 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Starling.Net; -using Starling.Net.Http; using StarlingUrlParser = global::Starling.Url.UrlParser; namespace Starling.Engine.Tests; @@ -288,9 +287,6 @@ public async Task StarlingHttpClient_reuses_a_single_TCP_connection_across_two_s server.AcceptCount.Should().Be(1, "the pool must reuse the kept-alive TCP connection across sequential GETs"); - client.ConnectionPool.IdleCountFor( - OriginKey.Create("http", "localhost", server.Port)) - .Should().Be(1, "after the second response the transport returns to the idle pool"); } [TestMethod] @@ -356,7 +352,6 @@ public async Task StarlingHttpClient_does_not_reuse_when_server_closes_the_conne (await client.GetAsync(url, CancellationToken.None)).IsOk.Should().BeTrue(); server.AcceptCount.Should().Be(2); - client.ConnectionPool.IdleCount.Should().Be(0); } [TestMethod] diff --git a/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs b/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs deleted file mode 100644 index fedefd99..00000000 --- a/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using AwesomeAssertions; -using Starling.Net.Dns; -namespace Starling.Net.Tests.Dns; - -[TestClass] -public class DnsMessageTests -{ - [TestMethod] - public void EncodeName_simple_hostname() - { - var bytes = DnsMessage.EncodeName("example.com"); - // 7,e,x,a,m,p,l,e, 3,c,o,m, 0 - bytes.Should().Equal( - 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e', - 0x03, (byte)'c', (byte)'o', (byte)'m', - 0x00); - } - - [TestMethod] - public void EncodeName_with_trailing_dot_is_same_as_without() - { - DnsMessage.EncodeName("example.com.").Should().Equal(DnsMessage.EncodeName("example.com")); - } - - [TestMethod] - public void EncodeName_empty_emits_root_only() - { - DnsMessage.EncodeName("").Should().Equal((byte)0); - } - - [TestMethod] - public void EncodeName_label_over_63_is_rejected() - { - var act = () => DnsMessage.EncodeName(new string('a', 64)); - act.Should().Throw(); - } - - [TestMethod] - public void BuildQuery_writes_expected_header() - { - var pkt = DnsMessage.BuildQuery(0xABCD, "example.com", DnsMessage.QType.A); - pkt.Length.Should().Be(12 + 13 + 4); // header + name + qtype/qclass - pkt[0].Should().Be(0xAB); - pkt[1].Should().Be(0xCD); - pkt[2].Should().Be(0x01); // RD=1 - pkt[3].Should().Be(0x00); - // qdcount=1 - pkt[4].Should().Be(0); - pkt[5].Should().Be(1); - } - - [TestMethod] - public void DecodeName_roundtrips_simple_name() - { - var encoded = DnsMessage.EncodeName("a.b.c"); - // Place at offset 0 of a buffer. - var (name, next) = DnsMessage.DecodeName(encoded, 0); - name.Should().Be("a.b.c"); - next.Should().Be(encoded.Length); - } - - [TestMethod] - public void DecodeName_follows_compression_pointer() - { - // Build a packet that contains "example.com" at offset 12, and a - // pointer at offset 30 referencing offset 12. - var name = DnsMessage.EncodeName("example.com"); - var pkt = new byte[12 + name.Length + 2]; - Array.Copy(name, 0, pkt, 12, name.Length); - pkt[12 + name.Length] = 0xC0; // pointer high byte - pkt[12 + name.Length + 1] = 12; // → offset 12 - - var (decoded, _) = DnsMessage.DecodeName(pkt, 12 + name.Length); - decoded.Should().Be("example.com"); - } - - [TestMethod] - public void Parse_response_with_one_A_answer() - { - // Build a tiny synthetic response: id, flags (QR=1, RA=1, RCODE=0), - // QDCOUNT=1, ANCOUNT=1. - var name = DnsMessage.EncodeName("example.com"); - var pkt = new byte[12 + name.Length + 4 // question - + name.Length + 10 + 4]; // answer - // Header - pkt[0] = 0xAB; pkt[1] = 0xCD; - pkt[2] = 0x81; // QR=1, RD=1 - pkt[3] = 0x80; // RA=1, RCODE=0 - pkt[5] = 1; // QDCOUNT - pkt[7] = 1; // ANCOUNT - - // Question - Array.Copy(name, 0, pkt, 12, name.Length); - var off = 12 + name.Length; - pkt[off + 1] = (byte)DnsMessage.QType.A; - pkt[off + 3] = (byte)DnsMessage.QClass.IN; - - // Answer (NAME, TYPE=A, CLASS=IN, TTL=300, RDLENGTH=4, RDATA=93.184.216.34) - var aoff = off + 4; - Array.Copy(name, 0, pkt, aoff, name.Length); - var raoff = aoff + name.Length; - pkt[raoff + 1] = (byte)DnsMessage.QType.A; - pkt[raoff + 3] = (byte)DnsMessage.QClass.IN; - pkt[raoff + 4] = 0; pkt[raoff + 5] = 0; - pkt[raoff + 6] = 0x01; pkt[raoff + 7] = 0x2C; // TTL = 300 - pkt[raoff + 8] = 0; pkt[raoff + 9] = 4; - pkt[raoff + 10] = 93; pkt[raoff + 11] = 184; - pkt[raoff + 12] = 216; pkt[raoff + 13] = 34; - - var (header, questions, answers) = DnsMessage.Parse(pkt); - header.Rcode.Should().Be(DnsMessage.RCode.NoError); - header.AnCount.Should().Be(1); - questions.Should().ContainSingle() - .Which.Name.Should().Be("example.com"); - answers.Should().ContainSingle() - .Which.Should().BeOfType() - .Which.IPv4.Should().Equal(93, 184, 216, 34); - ((DnsMessage.AAnswer)answers[0]).Ttl.Should().Be(300u); - } -} diff --git a/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs b/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs deleted file mode 100644 index 7d050f22..00000000 --- a/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Net; -using AwesomeAssertions; -using Starling.Net.Dns; -namespace Starling.Net.Tests.Dns; - -[TestClass] -public class DnsResolverTests -{ - [TestMethod] - public async Task Localhost_short_circuits_to_loopback_addresses() - { - var resolver = new DnsResolver(new FailingTransport()); - var ct = CancellationToken.None; - var r = await resolver.ResolveAsync("localhost", ct); - r.IsOk.Should().BeTrue(); - r.Value.Addresses.Should().Contain(IPAddress.Loopback); - } - - [TestMethod] - public async Task Numeric_dotted_quad_passes_through_without_query() - { - var resolver = new DnsResolver(new FailingTransport()); - var ct = CancellationToken.None; - var r = await resolver.ResolveAsync("8.8.8.8", ct); - r.IsOk.Should().BeTrue(); - r.Value.Addresses.Should().ContainSingle() - .Which.Should().Be(IPAddress.Parse("8.8.8.8")); - } - - [TestMethod] - public async Task Empty_hostname_is_an_error() - { - var resolver = new DnsResolver(new FailingTransport()); - var ct = CancellationToken.None; - var r = await resolver.ResolveAsync(" ", ct); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(DnsError.EmptyHostname); - } - - [TestMethod] - public async Task Query_against_fake_transport_returns_parsed_address() - { - // FakeDnsTransport returns a canned response for example.com → 93.184.216.34. - var transport = new FakeDnsTransport(); - var resolver = new DnsResolver(transport, new DnsCache(), () => 0x1234); - var ct = CancellationToken.None; - var r = await resolver.ResolveAsync("example.com", ct); - r.IsOk.Should().BeTrue(); - r.Value.Addresses.Should().Contain(IPAddress.Parse("93.184.216.34")); - } - - [TestMethod] - public async Task Cached_result_skips_transport() - { - var transport = new FakeDnsTransport(); - var cache = new DnsCache(); - var resolver = new DnsResolver(transport, cache, () => 0x1234); - var ct = CancellationToken.None; - - await resolver.ResolveAsync("example.com", ct); - var firstCalls = transport.CallCount; - - await resolver.ResolveAsync("example.com", ct); - transport.CallCount.Should().Be(firstCalls, - because: "cache should serve the second lookup"); - } - - [TestMethod] - public async Task NoRecords_when_response_has_zero_answers() - { - // A response with NOERROR but ANCOUNT=0. - var transport = new EmptyDnsTransport(); - var resolver = new DnsResolver(transport, new DnsCache(), () => 0xABCD); - var ct = CancellationToken.None; - var r = await resolver.ResolveAsync("nowhere.example", ct); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(DnsError.NoRecords); - } - - // ----------------------------------------------------------------------- - // Cache unit tests - // ----------------------------------------------------------------------- - - [TestMethod] - public void Cache_returns_cached_within_ttl_and_evicts_after() - { - var fakeNow = new MutableClock(DateTimeOffset.UtcNow); - var cache = new DnsCache(now: () => fakeNow.Now); - - var r = new DnsResult("example.com", [IPAddress.Loopback], TimeSpan.FromSeconds(10)); - cache.Put("example.com", r); - - cache.TryGet("example.com", out var hit).Should().BeTrue(); - hit.Hostname.Should().Be("example.com"); - - fakeNow.Now += TimeSpan.FromSeconds(11); - cache.TryGet("example.com", out _).Should().BeFalse(); - } - - [TestMethod] - public void Cache_evicts_oldest_when_over_capacity() - { - var cache = new DnsCache(maxEntries: 2); - cache.Put("a", new DnsResult("a", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - cache.Put("b", new DnsResult("b", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - cache.Put("c", new DnsResult("c", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - - cache.Count.Should().Be(2); - cache.TryGet("a", out _).Should().BeFalse(); - cache.TryGet("b", out _).Should().BeTrue(); - cache.TryGet("c", out _).Should().BeTrue(); - } - - [TestMethod] - public void Cache_recent_access_bumps_LRU_order() - { - var cache = new DnsCache(maxEntries: 2); - cache.Put("a", new DnsResult("a", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - cache.Put("b", new DnsResult("b", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - // Touching 'a' makes 'b' the LRU candidate for eviction. - cache.TryGet("a", out _); - cache.Put("c", new DnsResult("c", [IPAddress.Loopback], TimeSpan.FromSeconds(60))); - - cache.TryGet("a", out _).Should().BeTrue(); - cache.TryGet("b", out _).Should().BeFalse(); - cache.TryGet("c", out _).Should().BeTrue(); - } - - // ----------------------------------------------------------------------- - // Test doubles - // ----------------------------------------------------------------------- - - private sealed class FailingTransport : IDnsTransport - { - public Task SendAsync(byte[] queryPacket, CancellationToken ct) - => throw new InvalidOperationException("test transport must not be called"); - } - - private sealed class EmptyDnsTransport : IDnsTransport - { - public Task SendAsync(byte[] queryPacket, CancellationToken ct) - { - // Echo the question section with QR=1, ANCOUNT=0. - var resp = new byte[queryPacket.Length]; - Array.Copy(queryPacket, resp, queryPacket.Length); - resp[2] = 0x81; // QR=1, RD=1 - resp[3] = 0x80; // RA=1 - // Leave ANCOUNT at 0. - return Task.FromResult(resp); - } - } - - private sealed class FakeDnsTransport : IDnsTransport - { - public int CallCount { get; private set; } - - public Task SendAsync(byte[] queryPacket, CancellationToken ct) - { - CallCount++; - - // Parse the question to discover the qtype + name. - var (h, qs, _) = DnsMessage.Parse(queryPacket); - if (qs.Count == 0) - { - throw new InvalidOperationException(); - } - - var q = qs[0]; - - // Build a response only for the A query; for AAAA return NOERROR - // with zero answers (simulates v4-only host). - if (q.Type != DnsMessage.QType.A) - { - var empty = new byte[queryPacket.Length]; - Array.Copy(queryPacket, empty, queryPacket.Length); - empty[2] = 0x81; empty[3] = 0x80; - empty[7] = 0; - return Task.FromResult(empty); - } - - // Manually assemble: header(12) + question + answer. - var name = DnsMessage.EncodeName(q.Name); - var resp = new byte[12 + name.Length + 4 + name.Length + 10 + 4]; - // Header - resp[0] = queryPacket[0]; resp[1] = queryPacket[1]; - resp[2] = 0x81; resp[3] = 0x80; - resp[5] = 1; - resp[7] = 1; - // Question - Array.Copy(name, 0, resp, 12, name.Length); - var qoff = 12 + name.Length; - resp[qoff + 1] = (byte)DnsMessage.QType.A; - resp[qoff + 3] = (byte)DnsMessage.QClass.IN; - // Answer - var aoff = qoff + 4; - Array.Copy(name, 0, resp, aoff, name.Length); - var roff = aoff + name.Length; - resp[roff + 1] = (byte)DnsMessage.QType.A; - resp[roff + 3] = (byte)DnsMessage.QClass.IN; - resp[roff + 6] = 0x01; resp[roff + 7] = 0x2C; // TTL = 300 - resp[roff + 9] = 4; - resp[roff + 10] = 93; resp[roff + 11] = 184; - resp[roff + 12] = 216; resp[roff + 13] = 34; - return Task.FromResult(resp); - } - } - - private sealed class MutableClock - { - public DateTimeOffset Now; - public MutableClock(DateTimeOffset start) { Now = start; } - } -} diff --git a/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs b/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs deleted file mode 100644 index f39d0fb2..00000000 --- a/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.IO.Compression; -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http.Decoding; -namespace Starling.Net.Tests.Http; - -[TestClass] -public class ChunkedReaderTests -{ - private static InboundBuffer FromString(string data) => - new(new MemoryStream(Encoding.ASCII.GetBytes(data))); - - [TestMethod] - public async Task Reads_single_chunk() - { - var src = FromString("5\r\nhello\r\n0\r\n\r\n"); - var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - Encoding.ASCII.GetString(bytes).Should().Be("hello"); - } - - [TestMethod] - public async Task Reads_multiple_chunks_in_order() - { - var src = FromString("5\r\nhello\r\n6\r\n world\r\n1\r\n!\r\n0\r\n\r\n"); - var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - Encoding.ASCII.GetString(bytes).Should().Be("hello world!"); - } - - [TestMethod] - public async Task Handles_chunk_extensions() - { - var src = FromString("5;name=value\r\nhello\r\n0;final=1\r\n\r\n"); - var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - Encoding.ASCII.GetString(bytes).Should().Be("hello"); - } - - [TestMethod] - public async Task Skips_trailers() - { - var src = FromString("5\r\nhello\r\n0\r\nX-Trailer-One: value\r\nX-Trailer-Two: more\r\n\r\n"); - var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - Encoding.ASCII.GetString(bytes).Should().Be("hello"); - } - - [TestMethod] - public async Task Accepts_uppercase_hex_chunk_size() - { - var src = FromString("FF\r\n" + new string('x', 0xFF) + "\r\n0\r\n\r\n"); - var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - bytes.Length.Should().Be(0xFF); - } - - [TestMethod] - public async Task Rejects_truncated_stream() - { - var src = FromString("5\r\nhel"); // not enough data - var act = async () => await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [TestMethod] - public async Task Rejects_missing_crlf_after_chunk_data() - { - var src = FromString("5\r\nhellobad"); - var act = async () => await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [TestMethod] - public async Task Enforces_body_size_cap() - { - var src = FromString("a\r\n0123456789\r\n0\r\n\r\n"); - var act = async () => await ChunkedReader.ReadAllAsync(src, 5, CancellationToken.None); - await act.Should().ThrowAsync().WithMessage("*exceeded cap*"); - } - - [TestMethod] - public void ParseChunkSize_handles_extensions() - { - var line = Encoding.ASCII.GetBytes("a;ext=1"); - ChunkedReader.ParseChunkSize(line).Should().Be(10); - } - - [TestMethod] - public void ParseChunkSize_rejects_empty() - { - var act = () => ChunkedReader.ParseChunkSize(Array.Empty()); - act.Should().Throw(); - } - - [TestMethod] - public void ParseChunkSize_rejects_non_hex() - { - var act = () => ChunkedReader.ParseChunkSize(Encoding.ASCII.GetBytes("xyz")); - act.Should().Throw(); - } -} - -[TestClass] -public class BodyDecoderTests -{ - [TestMethod] - public void Identity_passes_through() - { - var input = Encoding.UTF8.GetBytes("hello"); - BodyDecoder.Decode(input, Array.Empty()) - .Should().Equal(input); - } - - [TestMethod] - public void Decodes_gzip() - { - var payload = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog"); - using var ms = new MemoryStream(); - using (var gz = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - gz.Write(payload); - } - - BodyDecoder.Decode(ms.ToArray(), new[] { "gzip" }) - .Should().Equal(payload); - } - - [TestMethod] - public void Decodes_brotli() - { - var payload = Encoding.UTF8.GetBytes("brotli compressed text payload, repeated. " + new string('a', 200)); - using var ms = new MemoryStream(); - using (var br = new BrotliStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - br.Write(payload); - } - - BodyDecoder.Decode(ms.ToArray(), new[] { "br" }) - .Should().Equal(payload); - } - - [TestMethod] - public void Decodes_deflate_with_zlib_wrapping() - { - var payload = Encoding.UTF8.GetBytes("zlib wrapped deflate payload"); - using var ms = new MemoryStream(); - using (var z = new ZLibStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - z.Write(payload); - } - - BodyDecoder.Decode(ms.ToArray(), new[] { "deflate" }) - .Should().Equal(payload); - } - - [TestMethod] - public void Decodes_raw_deflate_when_no_zlib_wrapping() - { - var payload = Encoding.UTF8.GetBytes("raw deflate payload, no header"); - using var ms = new MemoryStream(); - using (var d = new DeflateStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - d.Write(payload); - } - - BodyDecoder.Decode(ms.ToArray(), new[] { "deflate" }) - .Should().Equal(payload); - } - - [TestMethod] - public void Decodes_stacked_encodings_in_reverse_order() - { - // Server applied gzip first, then brotli. To recover identity we must - // peel brotli first, then gzip — i.e. iterate the list in reverse. - var payload = Encoding.UTF8.GetBytes("stacked encoding payload"); - byte[] gz, brOverGz; - using (var ms = new MemoryStream()) - { - using (var z = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - z.Write(payload); - } - - gz = ms.ToArray(); - } - using (var ms = new MemoryStream()) - { - using (var b = new BrotliStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - b.Write(gz); - } - - brOverGz = ms.ToArray(); - } - - BodyDecoder.Decode(brOverGz, new[] { "gzip", "br" }) - .Should().Equal(payload); - } - - [TestMethod] - [DataRow("gzip, br", new[] { "gzip", "br" })] - [DataRow(" gzip , identity, br ", new[] { "gzip", "br" })] - [DataRow("identity", new string[0])] - [DataRow("", new string[0])] - [DataRow(null, new string[0])] - public void ParseEncodings_handles_common_inputs(string? header, string[] expected) - { - BodyDecoder.ParseEncodings(header).Should().Equal(expected); - } - - [TestMethod] - public void Rejects_unknown_encoding() - { - var act = () => BodyDecoder.Decode(new byte[] { 1, 2, 3 }, new[] { "compress" }); - act.Should().Throw(); - } -} diff --git a/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs b/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs deleted file mode 100644 index 18252a8c..00000000 --- a/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http; -namespace Starling.Net.Tests.Http; - -/// -/// End-to-end tests that exercise 's use of the -/// connection pool. Drives a real loopback HTTP/1.1 server that holds the TCP -/// connection open for keep-alive responses, then asserts the second request -/// reused the same socket (no new accept) or didn't (new accept). -/// -[TestClass] -public class ConnectionPoolIntegrationTests -{ - [TestMethod] - public async Task Second_same_origin_request_reuses_pooled_connection() - { - using var server = await KeepAliveStubServer.StartAsync(req => - ResponseBuilder.KeepAlive("hi", "text/plain")); - - using var client = new StarlingHttpClient(); - var url = $"http://localhost:{server.Port}/"; - - var r1 = await client.GetAsync(url, CancellationToken.None); - var r2 = await client.GetAsync(url, CancellationToken.None); - - r1.IsOk.Should().BeTrue(); - r2.IsOk.Should().BeTrue(); - - // Same TCP connection serviced both requests: the server should only - // have accepted once. - server.AcceptCount.Should().Be(1, "the second request must reuse the pooled connection"); - server.RequestCount.Should().Be(2); - - // After the second request completes and is released, the pool should - // still hold one idle entry for this origin. - var origin = OriginKey.Create("http", "localhost", server.Port); - client.ConnectionPool.IdleCountFor(origin).Should().Be(1); - } - - [TestMethod] - public async Task Different_origin_does_not_reuse_pooled_connection() - { - using var serverA = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.KeepAlive("a", "text/plain")); - using var serverB = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.KeepAlive("b", "text/plain")); - - using var client = new StarlingHttpClient(); - - var r1 = await client.GetAsync( - $"http://localhost:{serverA.Port}/", CancellationToken.None); - var r2 = await client.GetAsync( - $"http://localhost:{serverB.Port}/", CancellationToken.None); - - r1.IsOk.Should().BeTrue(); - r2.IsOk.Should().BeTrue(); - - serverA.AcceptCount.Should().Be(1); - serverB.AcceptCount.Should().Be(1, "different origin must not pull from the other origin's pool"); - - var originA = OriginKey.Create("http", "localhost", serverA.Port); - var originB = OriginKey.Create("http", "localhost", serverB.Port); - client.ConnectionPool.IdleCountFor(originA).Should().Be(1); - client.ConnectionPool.IdleCountFor(originB).Should().Be(1); - } - - [TestMethod] - public async Task Connection_close_response_is_not_pooled() - { - using var server = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.Close("bye", "text/plain")); - - using var client = new StarlingHttpClient(); - var origin = OriginKey.Create("http", "localhost", server.Port); - - var r1 = await client.GetAsync( - $"http://localhost:{server.Port}/", CancellationToken.None); - r1.IsOk.Should().BeTrue(); - - client.ConnectionPool.IdleCountFor(origin).Should().Be(0, - "Connection: close responses are never returned to the pool"); - - var r2 = await client.GetAsync( - $"http://localhost:{server.Port}/", CancellationToken.None); - r2.IsOk.Should().BeTrue(); - - server.AcceptCount.Should().Be(2, "the second request must open a new connection"); - } - - [TestMethod] - public async Task Idle_timeout_evicts_pooled_connection_on_next_acquire() - { - using var server = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.KeepAlive("ok", "text/plain")); - - // Tiny idle timeout so we can age the entry out without waiting. - var pool = new ConnectionPool(maxPerOrigin: 6, idleTimeout: TimeSpan.FromMilliseconds(50)); - using var client = new StarlingHttpClient( - new StarlingHttpClientOptions { ConnectionPool = pool }); - var origin = OriginKey.Create("http", "localhost", server.Port); - - var r1 = await client.GetAsync( - $"http://localhost:{server.Port}/", CancellationToken.None); - r1.IsOk.Should().BeTrue(); - pool.IdleCountFor(origin).Should().Be(1); - - // Age the idle entry past the timeout, then trigger drain. After that - // a subsequent request must open a fresh socket. - await Task.Delay(150, CancellationToken.None); - var drained = await pool.DrainExpiredAsync(); - drained.Should().Be(1, "the idle entry exceeded the configured idle timeout"); - pool.IdleCountFor(origin).Should().Be(0); - - var r2 = await client.GetAsync( - $"http://localhost:{server.Port}/", CancellationToken.None); - r2.IsOk.Should().BeTrue(); - server.AcceptCount.Should().Be(2, "expired connection must not be reused"); - } - - [TestMethod] - public async Task Disposing_client_disposes_pooled_connections() - { - var server = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.KeepAlive("ok", "text/plain")); - - var client = new StarlingHttpClient(); - var origin = OriginKey.Create("http", "localhost", server.Port); - - try - { - var r1 = await client.GetAsync( - $"http://localhost:{server.Port}/", CancellationToken.None); - r1.IsOk.Should().BeTrue(); - client.ConnectionPool.IdleCountFor(origin).Should().Be(1); - } - finally - { - client.Dispose(); - server.Dispose(); - } - - // After disposal the pool's idle queues should be empty. - client.ConnectionPool.IdleCount.Should().Be(0); - } - - [TestMethod] - public async Task Concurrent_same_origin_requests_open_separate_connections_then_pool_both() - { - // Two requests fired off in parallel against an empty pool must each - // open their own socket (no serialization). When both complete and the - // server kept them alive, both should land in the pool. - using var server = await KeepAliveStubServer.StartAsync(_ => - ResponseBuilder.KeepAlive("ok", "text/plain")); - - using var client = new StarlingHttpClient(); - var url = $"http://localhost:{server.Port}/"; - - var t1 = client.GetAsync(url, CancellationToken.None); - var t2 = client.GetAsync(url, CancellationToken.None); - - var results = await Task.WhenAll(t1, t2); - results[0].IsOk.Should().BeTrue(); - results[1].IsOk.Should().BeTrue(); - - server.AcceptCount.Should().Be(2, - "parallel requests on an empty pool must open separate connections"); - - var origin = OriginKey.Create("http", "localhost", server.Port); - client.ConnectionPool.IdleCountFor(origin).Should().Be(2, - "both kept-alive connections return to the pool when finished"); - } - - private static class ResponseBuilder - { - public static byte[] KeepAlive(string body, string contentType) - { - var bytes = Encoding.UTF8.GetBytes(body); - var head = Encoding.ASCII.GetBytes( - "HTTP/1.1 200 OK\r\n" + - $"Content-Type: {contentType}\r\n" + - $"Content-Length: {bytes.Length}\r\n" + - "Connection: keep-alive\r\n\r\n"); - return Concat(head, bytes); - } - - public static byte[] Close(string body, string contentType) - { - var bytes = Encoding.UTF8.GetBytes(body); - var head = Encoding.ASCII.GetBytes( - "HTTP/1.1 200 OK\r\n" + - $"Content-Type: {contentType}\r\n" + - $"Content-Length: {bytes.Length}\r\n" + - "Connection: close\r\n\r\n"); - return Concat(head, bytes); - } - - private static byte[] Concat(byte[] a, byte[] b) - { - var c = new byte[a.Length + b.Length]; - Buffer.BlockCopy(a, 0, c, 0, a.Length); - Buffer.BlockCopy(b, 0, c, a.Length, b.Length); - return c; - } - } -} - -/// -/// Multi-request HTTP/1.1 stub. Unlike this one -/// keeps each accepted socket open after writing a response so it can service -/// further requests over the same TCP connection (true HTTP/1.1 keep-alive). -/// A connection terminates when the handler returns a response that asks for -/// Connection: close, the peer closes, or the server is disposed. -/// -internal sealed class KeepAliveStubServer : IDisposable -{ - private readonly TcpListener _listener; - private readonly CancellationTokenSource _cts = new(); - private readonly Task _accept; - private readonly Func _handler; - private int _accepts; - private int _requests; - - public int Port { get; } - public int AcceptCount => Volatile.Read(ref _accepts); - public int RequestCount => Volatile.Read(ref _requests); - - private KeepAliveStubServer(TcpListener listener, Func handler) - { - _listener = listener; - _handler = handler; - Port = ((IPEndPoint)listener.LocalEndpoint).Port; - _accept = Task.Run(AcceptLoop); - } - - public static Task StartAsync(Func handler) - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return Task.FromResult(new KeepAliveStubServer(listener, handler)); - } - - private async Task AcceptLoop() - { - try - { - while (!_cts.IsCancellationRequested) - { - var client = await _listener.AcceptTcpClientAsync(_cts.Token); - Interlocked.Increment(ref _accepts); - _ = Task.Run(() => ServeAsync(client)); - } - } - catch (OperationCanceledException) { } - catch (ObjectDisposedException) { } - catch (SocketException) { } - } - - private async Task ServeAsync(TcpClient client) - { - using (client) - { - using var stream = client.GetStream(); - var buffer = new byte[8192]; - - try - { - while (!_cts.IsCancellationRequested) - { - var pos = 0; - while (pos < buffer.Length) - { - var n = await stream.ReadAsync(buffer.AsMemory(pos), _cts.Token); - if (n == 0) - { - // Peer closed between requests — normal end of life. - return; - } - pos += n; - if (ContainsCrLfCrLf(buffer.AsSpan(0, pos))) - { - break; - } - } - if (pos == 0) - { - return; - } - - var req = Encoding.ASCII.GetString(buffer, 0, pos); - Interlocked.Increment(ref _requests); - - var response = _handler(req); - await stream.WriteAsync(response, _cts.Token); - await stream.FlushAsync(_cts.Token); - - if (ResponseClosesConnection(response)) - { - return; // honor server-side close - } - } - } - catch (OperationCanceledException) { } - catch (IOException) { } - catch (ObjectDisposedException) { } - } - } - - private static bool ContainsCrLfCrLf(ReadOnlySpan data) - { - for (var i = 0; i + 3 < data.Length; i++) - { - if (data[i] == 0x0D && data[i + 1] == 0x0A && - data[i + 2] == 0x0D && data[i + 3] == 0x0A) - { - return true; - } - } - return false; - } - - private static bool ResponseClosesConnection(byte[] response) - { - var text = Encoding.ASCII.GetString(response); - var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); - if (headEnd < 0) - { - return true; - } - - var head = text[..headEnd]; - foreach (var line in head.Split("\r\n")) - { - if (!line.StartsWith("Connection:", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return line.Contains("close", StringComparison.OrdinalIgnoreCase); - } - return false; - } - - public void Dispose() - { - _cts.Cancel(); - _listener.Stop(); - try { _accept.Wait(TimeSpan.FromSeconds(2)); } catch { } - _cts.Dispose(); - } -} diff --git a/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs b/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs deleted file mode 100644 index 34cc5658..00000000 --- a/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using AwesomeAssertions; -using Starling.Net.Http; -namespace Starling.Net.Tests.Http; - -[TestClass] -public class ConnectionPoolTests -{ - private static OriginKey Origin(string host = "example.test", int port = 443) - => OriginKey.Create("https", host, port); - - [TestMethod] - public async Task TryAcquire_returns_the_same_transport_after_release() - { - var pool = new ConnectionPool(); - var origin = Origin(); - var fake = new FakeTransport(origin); - - await pool.ReleaseAsync(fake); - var acquired = pool.TryAcquire(origin); - - acquired.Should().BeSameAs(fake); - pool.IdleCount.Should().Be(0); - fake.Disposed.Should().BeFalse("the transport was acquired by the caller, not discarded"); - } - - [TestMethod] - public async Task TryAcquire_returns_null_when_pool_is_empty() - { - var pool = new ConnectionPool(); - pool.TryAcquire(Origin()).Should().BeNull(); - await pool.DisposeAsync(); - } - - [TestMethod] - public async Task TryAcquire_keys_on_origin_distinguishes_scheme_host_port() - { - var pool = new ConnectionPool(); - var http = OriginKey.Create("http", "example.test", 80); - var https = OriginKey.Create("https", "example.test", 443); - var altPort = OriginKey.Create("https", "example.test", 8443); - var altHost = OriginKey.Create("https", "other.test", 443); - - var t1 = new FakeTransport(http); - var t2 = new FakeTransport(https); - var t3 = new FakeTransport(altPort); - var t4 = new FakeTransport(altHost); - await pool.ReleaseAsync(t1); - await pool.ReleaseAsync(t2); - await pool.ReleaseAsync(t3); - await pool.ReleaseAsync(t4); - - pool.TryAcquire(http).Should().BeSameAs(t1); - pool.TryAcquire(https).Should().BeSameAs(t2); - pool.TryAcquire(altPort).Should().BeSameAs(t3); - pool.TryAcquire(altHost).Should().BeSameAs(t4); - - await pool.DisposeAsync(); - } - - [TestMethod] - public async Task Disposed_transport_is_not_returned_from_acquire() - { - var pool = new ConnectionPool(); - var origin = Origin(); - var fake = new FakeTransport(origin); - await pool.ReleaseAsync(fake); - - fake.SimulatePeerClose(); - pool.TryAcquire(origin).Should().BeNull("a stale entry must be dropped silently"); - pool.IdleCount.Should().Be(0); - fake.Disposed.Should().BeTrue("stale entries are discarded when encountered"); - } - - [TestMethod] - public async Task Releasing_a_closed_transport_does_not_pool_it() - { - var pool = new ConnectionPool(); - var origin = Origin(); - var fake = new FakeTransport(origin); - fake.SimulatePeerClose(); - - await pool.ReleaseAsync(fake); - - pool.IdleCount.Should().Be(0); - fake.Disposed.Should().BeTrue(); - } - - [TestMethod] - public async Task DrainExpired_disposes_entries_older_than_idle_timeout() - { - var pool = new ConnectionPool(maxPerOrigin: 6, idleTimeout: TimeSpan.FromMilliseconds(50)); - var origin = Origin(); - var stale = new FakeTransport(origin); - var fresh = new FakeTransport(origin); - - await pool.ReleaseAsync(stale); - // Make sure the second release is later than the first. - await Task.Delay(100, CancellationToken.None); - await pool.ReleaseAsync(fresh); - - // Pretend "now" is 75ms after the stale entry but before fresh's expiry. - var now = DateTimeOffset.UtcNow; - var drained = await pool.DrainExpiredAsync(now); - - drained.Should().Be(1); - stale.Disposed.Should().BeTrue("stale entry expired and was disposed"); - fresh.Disposed.Should().BeFalse("fresh entry is still within the idle window"); - pool.IdleCount.Should().Be(1); - } - - [TestMethod] - public async Task Pool_capacity_is_bounded_and_oldest_is_evicted() - { - var pool = new ConnectionPool(maxPerOrigin: 2, idleTimeout: TimeSpan.FromMinutes(5)); - var origin = Origin(); - var t1 = new FakeTransport(origin) { Tag = "first" }; - var t2 = new FakeTransport(origin) { Tag = "second" }; - var t3 = new FakeTransport(origin) { Tag = "third" }; - - await pool.ReleaseAsync(t1); - await pool.ReleaseAsync(t2); - await pool.ReleaseAsync(t3); // should evict t1 (oldest) - - pool.IdleCount.Should().Be(2); - t1.Disposed.Should().BeTrue("oldest entry must be LRU-evicted to make room"); - t2.Disposed.Should().BeFalse(); - t3.Disposed.Should().BeFalse(); - - // MRU acquisition order: t3, then t2. - pool.TryAcquire(origin).Should().BeSameAs(t3); - pool.TryAcquire(origin).Should().BeSameAs(t2); - pool.TryAcquire(origin).Should().BeNull(); - } - - [TestMethod] - public async Task DisposeAll_disposes_every_entry_across_origins() - { - var pool = new ConnectionPool(); - var a = new FakeTransport(Origin("a.test")); - var b = new FakeTransport(Origin("b.test")); - var c = new FakeTransport(Origin("c.test")); - await pool.ReleaseAsync(a); - await pool.ReleaseAsync(b); - await pool.ReleaseAsync(c); - - await pool.DisposeAllAsync(); - - pool.IdleCount.Should().Be(0); - a.Disposed.Should().BeTrue(); - b.Disposed.Should().BeTrue(); - c.Disposed.Should().BeTrue(); - } - - [TestMethod] - public async Task Release_on_disposed_pool_closes_the_transport() - { - var pool = new ConnectionPool(); - await pool.DisposeAsync(); - var fake = new FakeTransport(Origin()); - - await pool.ReleaseAsync(fake); - - pool.IdleCount.Should().Be(0); - fake.Disposed.Should().BeTrue("disposed pools refuse new releases and clean up the transport"); - } - - [TestMethod] - public void Constructor_rejects_zero_capacity_or_non_positive_timeout() - { - Action zero = () => new ConnectionPool(0, TimeSpan.FromSeconds(1)); - Action negative = () => new ConnectionPool(2, TimeSpan.Zero); - zero.Should().Throw(); - negative.Should().Throw(); - } - - [TestMethod] - public void OriginKey_normalises_scheme_and_host_to_lowercase() - { - var a = OriginKey.Create("HTTPS", "Example.COM", 443); - var b = OriginKey.Create("https", "example.com", 443); - a.Should().Be(b); - } - - /// - /// In-memory stand-in used by the pool tests. - /// Exposes a flag so assertions can verify the pool disposed (or didn't - /// dispose) the entry, plus a way to simulate the peer closing the - /// connection while the transport was idle. - /// - private sealed class FakeTransport : IHttpTransport - { - public OriginKey Origin { get; } - public Stream Stream { get; } = new MemoryStream(); - public string? Alpn => null; - public Starling.Net.Tls.CertificateSummary? PeerCertificate => null; - public bool Disposed { get; private set; } - public bool Open { get; private set; } = true; - public string Tag { get; init; } = ""; - - public FakeTransport(OriginKey origin) => Origin = origin; - - public bool IsOpen => Open && !Disposed; - - public void SimulatePeerClose() => Open = false; - - public ValueTask DisposeAsync() - { - Disposed = true; - return ValueTask.CompletedTask; - } - } -} diff --git a/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs b/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs deleted file mode 100644 index 69665702..00000000 --- a/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http; -using Starling.Net.Http.H1; -using StarlingUrl = global::Starling.Url.Url; -using StarlingUrlParser = global::Starling.Url.UrlParser; - -namespace Starling.Net.Tests.Http; - -[TestClass] -public class H1RequestWriterTests -{ - private static StarlingUrl ParseUrl(string s) - { - var r = StarlingUrlParser.Parse(s); - r.IsOk.Should().BeTrue($"failed to parse {s}"); - return r.Value; - } - - [TestMethod] - public void Writes_request_line_with_origin_form_path() - { - var req = HttpRequest.Get(ParseUrl("https://example.com/foo/bar?x=1")); - var writer = new H1RequestWriter(); - - var bytes = writer.SerializeHead(req); - var text = Encoding.ASCII.GetString(bytes); - - text.Should().StartWith("GET /foo/bar?x=1 HTTP/1.1\r\n"); - } - - [TestMethod] - public void Defaults_path_to_slash_when_url_path_is_empty() - { - var req = HttpRequest.Get(ParseUrl("https://example.com")); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - text.Should().StartWith("GET / HTTP/1.1\r\n"); - } - - [TestMethod] - public void Adds_default_headers_when_missing() - { - var req = HttpRequest.Get(ParseUrl("https://example.com/")); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().Contain("Host: example.com\r\n"); - text.Should().Contain("User-Agent: Starling/0.1"); - text.Should().Contain("Accept: text/html"); - text.Should().Contain("Accept-Encoding: gzip, br, deflate\r\n"); - text.Should().Contain("Connection: keep-alive\r\n"); - text.Should().EndWith("\r\n\r\n"); - } - - [TestMethod] - public void Includes_explicit_port_in_host_header_when_not_default() - { - var req = HttpRequest.Get(ParseUrl("http://example.com:8080/")); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - text.Should().Contain("Host: example.com:8080\r\n"); - } - - [TestMethod] - public void Omits_default_port_in_host_header() - { - var req = HttpRequest.Get(ParseUrl("https://example.com:443/")); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - text.Should().Contain("Host: example.com\r\n"); - text.Should().NotContain(":443"); - } - - [TestMethod] - public void User_provided_headers_override_defaults() - { - var headers = new HttpHeaders(); - headers.Add("Accept-Encoding", "identity"); - headers.Add("User-Agent", "Custom/1.0"); - - var req = HttpRequest.Get(ParseUrl("https://example.com/"), headers); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().Contain("Accept-Encoding: identity\r\n"); - text.Should().Contain("User-Agent: Custom/1.0\r\n"); - text.Should().NotContain("Accept-Encoding: gzip"); - text.Should().NotContain("Starling/0.1"); - } - - [TestMethod] - public void Adds_content_length_when_body_present_and_caller_did_not_specify() - { - var url = ParseUrl("https://example.com/post"); - var body = Encoding.UTF8.GetBytes("hello=world"); - var req = new HttpRequest("POST", url, headers: null, body: body); - - var writer = new H1RequestWriter(); - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().Contain($"Content-Length: {body.Length}\r\n"); - } - - [TestMethod] - public void Adds_content_length_zero_for_empty_body_post() - { - // An empty-body POST must still carry Content-Length: 0 — servers reject - // a bodyless POST without it (411 Length Required). Regression for the - // McMaster token-authorization POST that 411'd before this fix. - var url = ParseUrl("https://example.com/tokenauthorization.aspx"); - var req = new HttpRequest("POST", url, headers: null, body: default); - - var writer = new H1RequestWriter(); - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().Contain("Content-Length: 0\r\n"); - } - - [TestMethod] - public void Omits_content_length_for_empty_body_get() - { - // GET/HEAD carry no body, so no Content-Length is emitted for an empty one. - var req = HttpRequest.Get(ParseUrl("https://example.com/")); - - var writer = new H1RequestWriter(); - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().NotContain("Content-Length:"); - } - - [TestMethod] - public void Omits_content_length_when_caller_specified_transfer_encoding() - { - var url = ParseUrl("https://example.com/post"); - var body = Encoding.UTF8.GetBytes("ignored"); - var headers = new HttpHeaders(); - headers.Add("Transfer-Encoding", "chunked"); - var req = new HttpRequest("POST", url, headers, body); - - var writer = new H1RequestWriter(); - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - - text.Should().NotContain("Content-Length:"); - text.Should().Contain("Transfer-Encoding: chunked\r\n"); - } - - [TestMethod] - public async Task WriteAsync_emits_header_block_then_body() - { - var url = ParseUrl("https://example.com/api"); - var body = Encoding.UTF8.GetBytes("{}"); - var headers = new HttpHeaders(); - headers.Add("Content-Type", "application/json"); - - var req = new HttpRequest("POST", url, headers, body); - - using var ms = new MemoryStream(); - await new H1RequestWriter().WriteAsync(req, ms, CancellationToken.None); - - var text = Encoding.UTF8.GetString(ms.ToArray()); - - text.Should().StartWith("POST /api HTTP/1.1\r\n"); - text.Should().Contain("Content-Type: application/json\r\n"); - text.Should().Contain($"Content-Length: {body.Length}\r\n"); - text.Should().EndWith("\r\n\r\n{}"); - } - - [TestMethod] - public void Sends_query_string_when_present() - { - var req = HttpRequest.Get(ParseUrl("https://example.com/search?q=hello+world&n=10")); - var writer = new H1RequestWriter(); - - var text = Encoding.ASCII.GetString(writer.SerializeHead(req)); - text.Should().StartWith("GET /search?q=hello+world&n=10 HTTP/1.1\r\n"); - } -} diff --git a/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs b/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs deleted file mode 100644 index 36f4b248..00000000 --- a/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System.IO.Compression; -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http; -using Starling.Net.Http.H1; -namespace Starling.Net.Tests.Http; - -[TestClass] -public class H1ResponseParserTests -{ - private static MemoryStream Bytes(string s) => new(Encoding.ASCII.GetBytes(s)); - - private static async Task Parse(string text, H1ResponseParser? parser = null) - { - var r = await (parser ?? new H1ResponseParser()).ParseAsync(Bytes(text), CancellationToken.None); - r.IsOk.Should().BeTrue($"parser returned {(r.IsOk ? "Ok" : r.Error.ToString())}"); - return r.Value; - } - - [TestMethod] - public async Task Parses_minimal_response_with_content_length() - { - var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - resp.HttpVersion.Should().Be("HTTP/1.1"); - resp.StatusCode.Should().Be(200); - resp.ReasonPhrase.Should().Be("OK"); - resp.Headers.GetFirst("Content-Length").Should().Be("5"); - Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hello"); - } - - [TestMethod] - public async Task Parses_response_with_empty_body() - { - var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - resp.Body.Length.Should().Be(0); - } - - [TestMethod] - public async Task Handles_status_204_with_no_body_even_without_content_length() - { - var resp = await Parse("HTTP/1.1 204 No Content\r\n\r\n"); - resp.StatusCode.Should().Be(204); - resp.Body.Length.Should().Be(0); - } - - [TestMethod] - public async Task Handles_status_304_with_no_body() - { - var resp = await Parse("HTTP/1.1 304 Not Modified\r\nETag: \"abc\"\r\n\r\n"); - resp.StatusCode.Should().Be(304); - resp.Body.Length.Should().Be(0); - } - - [TestMethod] - public async Task Skips_1xx_informational_responses() - { - var raw = - "HTTP/1.1 100 Continue\r\n\r\n" + - "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi"; - var resp = await Parse(raw); - resp.StatusCode.Should().Be(200); - Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hi"); - } - - [TestMethod] - public async Task Reads_chunked_body() - { - var raw = - "HTTP/1.1 200 OK\r\n" + - "Transfer-Encoding: chunked\r\n" + - "\r\n" + - "5\r\nhello\r\n" + - "6\r\n world\r\n" + - "1\r\n!\r\n" + - "0\r\n\r\n"; - var resp = await Parse(raw); - Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hello world!"); - } - - [TestMethod] - public async Task Reads_close_delimited_body_when_no_framing_headers() - { - var raw = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nclose-delimited body"; - var resp = await Parse(raw); - Encoding.ASCII.GetString(resp.Body.Span).Should().Be("close-delimited body"); - } - - [TestMethod] - public async Task Decodes_gzip_content_encoding() - { - var payload = Encoding.UTF8.GetBytes("compressed text body"); - using var compressedStream = new MemoryStream(); - using (var gz = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) - { - gz.Write(payload); - } - - var compressed = compressedStream.ToArray(); - - var head = $"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {compressed.Length}\r\n\r\n"; - var headBytes = Encoding.ASCII.GetBytes(head); - - var combined = new byte[headBytes.Length + compressed.Length]; - Buffer.BlockCopy(headBytes, 0, combined, 0, headBytes.Length); - Buffer.BlockCopy(compressed, 0, combined, headBytes.Length, compressed.Length); - - var parser = new H1ResponseParser(); - var result = await parser.ParseAsync(new MemoryStream(combined), CancellationToken.None); - result.IsOk.Should().BeTrue(); - result.Value.Body.ToArray().Should().Equal(payload); - } - - [TestMethod] - public async Task Decodes_chunked_then_gzip() - { - var payload = Encoding.UTF8.GetBytes("the standard chunked + gzip combo most CDNs send"); - using var ms = new MemoryStream(); - using (var gz = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true)) - { - gz.Write(payload); - } - - var compressed = ms.ToArray(); - - // Chunk the compressed bytes in two halves. - var half = compressed.Length / 2; - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Encoding: gzip\r\n\r\n"); - - var head = Encoding.ASCII.GetBytes(sb.ToString()); - var first = new byte[half]; - Buffer.BlockCopy(compressed, 0, first, 0, half); - var second = new byte[compressed.Length - half]; - Buffer.BlockCopy(compressed, half, second, 0, second.Length); - - using var stream = new MemoryStream(); - stream.Write(head); - stream.Write(Encoding.ASCII.GetBytes($"{first.Length:x}\r\n")); - stream.Write(first); - stream.Write(Encoding.ASCII.GetBytes("\r\n")); - stream.Write(Encoding.ASCII.GetBytes($"{second.Length:x}\r\n")); - stream.Write(second); - stream.Write(Encoding.ASCII.GetBytes("\r\n")); - stream.Write(Encoding.ASCII.GetBytes("0\r\n\r\n")); - stream.Position = 0; - - var parser = new H1ResponseParser(); - var result = await parser.ParseAsync(stream, CancellationToken.None); - result.IsOk.Should().BeTrue(); - result.Value.Body.ToArray().Should().Equal(payload); - } - - [TestMethod] - public async Task Multivalued_set_cookie_headers_are_all_visible() - { - var raw = - "HTTP/1.1 200 OK\r\n" + - "Set-Cookie: a=1\r\n" + - "Set-Cookie: b=2\r\n" + - "Content-Length: 0\r\n\r\n"; - var resp = await Parse(raw); - resp.Headers.GetAll("Set-Cookie").Should().Equal(new[] { "a=1", "b=2" }); - } - - [TestMethod] - public async Task Bad_status_line_returns_error() - { - var parser = new H1ResponseParser(); - var r = await parser.ParseAsync(Bytes("nope nope nope\r\n\r\n"), CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.BadStatusLine); - } - - [TestMethod] - public async Task Missing_colon_in_header_returns_BadHeader() - { - var parser = new H1ResponseParser(); - var r = await parser.ParseAsync( - Bytes("HTTP/1.1 200 OK\r\nNoColonHere\r\n\r\n"), - CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.BadHeader); - } - - [TestMethod] - public async Task Header_block_too_large_returns_HeadersTooLarge() - { - var giantHeader = "X-Big: " + new string('a', 70_000) + "\r\n"; - var raw = "HTTP/1.1 200 OK\r\n" + giantHeader + "Content-Length: 0\r\n\r\n"; - var parser = new H1ResponseParser { MaxHeaderBlockBytes = 32 * 1024 }; - var r = await parser.ParseAsync(Bytes(raw), CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.HeadersTooLarge); - } - - [TestMethod] - public async Task Truncated_body_returns_UnexpectedEof() - { - var parser = new H1ResponseParser(); - // Promise 10 bytes, deliver 3. - var r = await parser.ParseAsync( - Bytes("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"), - CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.UnexpectedEof); - } - - [TestMethod] - public async Task Body_size_cap_returns_BodyTooLarge_for_content_length() - { - var parser = new H1ResponseParser { MaxBodyBytes = 4 }; - var r = await parser.ParseAsync( - Bytes("HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n12345678"), - CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.BodyTooLarge); - } - - [TestMethod] - public async Task Unknown_content_encoding_returns_UnsupportedEncoding() - { - var parser = new H1ResponseParser(); - var r = await parser.ParseAsync( - Bytes("HTTP/1.1 200 OK\r\nContent-Encoding: compress\r\nContent-Length: 3\r\n\r\nabc"), - CancellationToken.None); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(HttpError.UnsupportedEncoding); - } - - [TestMethod] - public async Task Reason_phrase_can_be_empty() - { - var resp = await Parse("HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n"); - resp.StatusCode.Should().Be(200); - resp.ReasonPhrase.Should().Be(""); - } - - [TestMethod] - public async Task IndicatesKeepAlive_defaults_to_true_for_HTTP11_without_Connection_header() - { - var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - H1ResponseParser.IndicatesKeepAlive(resp).Should().BeTrue(); - } - - [TestMethod] - public async Task IndicatesKeepAlive_false_when_HTTP11_response_has_Connection_close() - { - var resp = await Parse("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - H1ResponseParser.IndicatesKeepAlive(resp).Should().BeFalse(); - } - - [TestMethod] - public async Task IndicatesKeepAlive_false_for_HTTP10_without_explicit_keep_alive() - { - var resp = await Parse("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"); - H1ResponseParser.IndicatesKeepAlive(resp).Should().BeFalse(); - } - - [TestMethod] - public async Task IndicatesKeepAlive_true_for_HTTP10_with_explicit_keep_alive() - { - var resp = await Parse("HTTP/1.0 200 OK\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n"); - H1ResponseParser.IndicatesKeepAlive(resp).Should().BeTrue(); - } - - [TestMethod] - public async Task HasDefiniteBodyFraming_true_for_Content_Length() - { - var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue(); - } - - [TestMethod] - public async Task HasDefiniteBodyFraming_true_for_chunked() - { - var resp = await Parse( - "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); - H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue(); - } - - [TestMethod] - public async Task HasDefiniteBodyFraming_true_for_204() - { - var resp = await Parse("HTTP/1.1 204 No Content\r\n\r\n"); - H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue(); - } - - [TestMethod] - public async Task HasDefiniteBodyFraming_false_for_close_delimited_body() - { - // No Content-Length, no Transfer-Encoding, non-204/304 status. - // The parser reads to EOF; the connection cannot be safely pooled. - var resp = await Parse("HTTP/1.0 200 OK\r\n\r\nhello"); - H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeFalse(); - } -} diff --git a/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs b/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs deleted file mode 100644 index 9f23aa79..00000000 --- a/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http; -using Starling.Net.Http.H2; -using Starling.Net.Http.H2.Hpack; -using UrlParser = global::Starling.Url.UrlParser; - -namespace Starling.Net.Tests.Http.H2; - -/// -/// End-to-end tests over a loopback socket driven by -/// a scripted minimal HTTP/2 server. Exercises the full path: preface, SETTINGS -/// exchange, HPACK-encoded request, and HEADERS+DATA response assembly, -/// including concurrent multiplexed streams. -/// -[TestClass] -public class H2ConnectionTests -{ - private static readonly OriginKey Origin = OriginKey.Create("https", "example.com", 443); - - [TestMethod] - public async Task Single_get_returns_status_and_body() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var (clientStream, serverStream) = await ConnectLoopbackAsync(); - - var serverTask = RunServerAsync(serverStream, cts.Token); - await using var conn = await H2Connection.StartAsync( - new FakeH2Transport(clientStream, Origin), Origin, null, null, cts.Token); - - var url = UrlParser.Parse("https://example.com/hello").Value; - var result = await conn.SendAsync(HttpRequest.Get(url), url, cts.Token); - - result.IsOk.Should().BeTrue(result.IsOk ? "" : result.Error.ToString()); - result.Value.StatusCode.Should().Be(200); - result.Value.HttpVersion.Should().Be("HTTP/2"); - result.Value.Headers.GetFirst("content-type").Should().Be("text/plain"); - Encoding.ASCII.GetString(result.Value.Body.Span).Should().Be("path=/hello"); - - await serverStream.DisposeAsync(); - await serverTask; - } - - [TestMethod] - public async Task Concurrent_streams_are_multiplexed_and_demultiplexed() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var (clientStream, serverStream) = await ConnectLoopbackAsync(); - - var serverTask = RunServerAsync(serverStream, cts.Token); - await using var conn = await H2Connection.StartAsync( - new FakeH2Transport(clientStream, Origin), Origin, null, null, cts.Token); - - // Fire several requests without awaiting; the connection must keep each - // response matched to its own stream. - var tasks = Enumerable.Range(0, 8).Select(i => - { - var url = UrlParser.Parse($"https://example.com/r{i}").Value; - return conn.SendAsync(HttpRequest.Get(url), url, cts.Token); - }).ToArray(); - - var results = await Task.WhenAll(tasks); - - for (var i = 0; i < results.Length; i++) - { - results[i].IsOk.Should().BeTrue(); - results[i].Value.StatusCode.Should().Be(200); - Encoding.ASCII.GetString(results[i].Value.Body.Span).Should().Be($"path=/r{i}"); - } - - await serverStream.DisposeAsync(); - await serverTask; - } - - [TestMethod] - public async Task Server_goaway_fails_outstanding_requests_retryably() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var (clientStream, serverStream) = await ConnectLoopbackAsync(); - - // Server that reads the preface, sends SETTINGS, then GOAWAY(lastId=0). - var serverTask = Task.Run(async () => - { - await ReadPrefaceAsync(serverStream, cts.Token); - await WriteEmptySettingsAsync(serverStream, cts.Token); - var writer = new H2FrameWriter(serverStream); - // Drain a couple of client frames, then refuse everything. - var reader = new H2FrameReader(serverStream, H2Protocol.DefaultMaxFrameSize); - await reader.ReadFrameAsync(cts.Token); - await writer.WriteGoAwayAsync(0, H2ErrorCode.NoError, cts.Token); - }, cts.Token); - - await using var conn = await H2Connection.StartAsync( - new FakeH2Transport(clientStream, Origin), Origin, null, null, cts.Token); - - var url = UrlParser.Parse("https://example.com/x").Value; - var result = await conn.SendAsync(HttpRequest.Get(url), url, cts.Token); - - result.IsErr.Should().BeTrue(); - result.Error.Should().Be(NetworkError.TransportFailure); // retryable - conn.IsUsable.Should().BeFalse(); - - await serverStream.DisposeAsync(); - await serverTask; - } - - // ---- Loopback plumbing ------------------------------------------------- - - private static async Task<(NetworkStream Client, NetworkStream Server)> ConnectLoopbackAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - try - { - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var client = new TcpClient(); - var acceptTask = listener.AcceptTcpClientAsync(); - await client.ConnectAsync(IPAddress.Loopback, port); - var server = await acceptTask; - client.NoDelay = true; - server.NoDelay = true; - return (client.GetStream(), server.GetStream()); - } - finally - { - listener.Stop(); - } - } - - // ---- Scripted server --------------------------------------------------- - - /// - /// Minimal HTTP/2 server: completes the handshake, then for each request - /// HEADERS replies 200 with a "path=<:path>" body so callers can verify - /// stream demultiplexing. - /// - private static async Task RunServerAsync(NetworkStream serverStream, CancellationToken ct) - { - try - { - await ReadPrefaceAsync(serverStream, ct); - await WriteEmptySettingsAsync(serverStream, ct); - - var reader = new H2FrameReader(serverStream, H2Protocol.DefaultMaxFrameSize); - var writer = new H2FrameWriter(serverStream); - var decoder = new HpackDecoder(H2Protocol.DefaultHeaderTableSize); - var encoder = new HpackEncoder(); - - while (true) - { - var maybe = await reader.ReadFrameAsync(ct).ConfigureAwait(false); - if (maybe is not { } frame) - { - break; - } - - switch (frame.Type) - { - case H2FrameType.Settings when !frame.HasFlag(H2Flags.Ack): - await writer.WriteSettingsAckAsync(ct); - break; - - case H2FrameType.Headers: - decoder.TryDecode(frame.Payload, out var fields).Should().BeTrue(); - var path = fields.First(f => f.Name == ":path").Value; - var body = Encoding.ASCII.GetBytes($"path={path}"); - var block = encoder.Encode([(":status", "200"), ("content-type", "text/plain")]); - await writer.WriteHeadersAsync( - frame.StreamId, block, endStream: false, H2Protocol.DefaultMaxFrameSize, ct); - await writer.WriteDataAsync(frame.StreamId, body, endStream: true, ct); - break; - - default: - break; // ignore WINDOW_UPDATE / PING / etc. - } - } - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) - { - // Client closed the connection — expected at end of test. - } - } - - private static async Task ReadPrefaceAsync(Stream stream, CancellationToken ct) - { - var preface = new byte[24]; - await stream.ReadExactlyAsync(preface, ct); - } - - private static Task WriteEmptySettingsAsync(Stream stream, CancellationToken ct) - { - // SETTINGS frame, length 0, flags 0, stream 0. - var frame = new byte[] { 0, 0, 0, (byte)H2FrameType.Settings, 0, 0, 0, 0, 0 }; - return stream.WriteAsync(frame, ct).AsTask(); - } - - private sealed class FakeH2Transport(Stream stream, OriginKey origin) : IHttpTransport - { - public OriginKey Origin { get; } = origin; - public Stream Stream { get; } = stream; - public string? Alpn => "h2"; - public Starling.Net.Tls.CertificateSummary? PeerCertificate => null; - public bool IsOpen { get; private set; } = true; - - public ValueTask DisposeAsync() - { - IsOpen = false; - return Stream.DisposeAsync(); - } - } -} diff --git a/tests/Starling.Net.Tests/Http/H2/H2FrameTests.cs b/tests/Starling.Net.Tests/Http/H2/H2FrameTests.cs deleted file mode 100644 index 22bf4e7b..00000000 --- a/tests/Starling.Net.Tests/Http/H2/H2FrameTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using AwesomeAssertions; -using Starling.Net.Http.H2; - -namespace Starling.Net.Tests.Http.H2; - -/// Frame writer → reader round-trips, including HEADERS fragmentation. -[TestClass] -public class H2FrameTests -{ - [TestMethod] - public async Task Data_window_update_and_rst_round_trip() - { - var ms = new MemoryStream(); - var writer = new H2FrameWriter(ms); - - var body = new byte[] { 1, 2, 3, 4, 5 }; - await writer.WriteDataAsync(1, body, endStream: true, CancellationToken.None); - await writer.WriteWindowUpdateAsync(0, 1000, CancellationToken.None); - await writer.WriteRstStreamAsync(3, H2ErrorCode.Cancel, CancellationToken.None); - - ms.Position = 0; - var reader = new H2FrameReader(ms, H2Protocol.DefaultMaxFrameSize); - - var data = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - data.Type.Should().Be(H2FrameType.Data); - data.StreamId.Should().Be(1); - data.HasFlag(H2Flags.EndStream).Should().BeTrue(); - data.Payload.Should().Equal(body); - - var win = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - win.Type.Should().Be(H2FrameType.WindowUpdate); - win.StreamId.Should().Be(0); - - var rst = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - rst.Type.Should().Be(H2FrameType.RstStream); - rst.StreamId.Should().Be(3); - rst.Payload[3].Should().Be((byte)H2ErrorCode.Cancel); - } - - [TestMethod] - public async Task Reader_returns_null_at_clean_eof() - { - var reader = new H2FrameReader(new MemoryStream(), H2Protocol.DefaultMaxFrameSize); - (await reader.ReadFrameAsync(CancellationToken.None)).Should().BeNull(); - } - - [TestMethod] - public async Task Large_headers_block_fragments_into_continuation() - { - var ms = new MemoryStream(); - var writer = new H2FrameWriter(ms); - - // 25-byte block with a 10-byte peer frame size → HEADERS(10) + - // CONTINUATION(10) + CONTINUATION(5, END_HEADERS). - var block = new byte[25]; - for (var i = 0; i < block.Length; i++) - { - block[i] = (byte)i; - } - - await writer.WriteHeadersAsync(1, block, endStream: true, peerMaxFrameSize: 10, CancellationToken.None); - - ms.Position = 0; - var reader = new H2FrameReader(ms, H2Protocol.DefaultMaxFrameSize); - - var f1 = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - f1.Type.Should().Be(H2FrameType.Headers); - f1.HasFlag(H2Flags.EndStream).Should().BeTrue(); - f1.HasFlag(H2Flags.EndHeaders).Should().BeFalse(); - f1.Payload.Length.Should().Be(10); - - var f2 = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - f2.Type.Should().Be(H2FrameType.Continuation); - f2.HasFlag(H2Flags.EndHeaders).Should().BeFalse(); - - var f3 = (await reader.ReadFrameAsync(CancellationToken.None))!.Value; - f3.Type.Should().Be(H2FrameType.Continuation); - f3.HasFlag(H2Flags.EndHeaders).Should().BeTrue(); - f3.Payload.Length.Should().Be(5); - - var reassembled = f1.Payload.Concat(f2.Payload).Concat(f3.Payload).ToArray(); - reassembled.Should().Equal(block); - } - - [TestMethod] - public async Task Reader_rejects_frame_larger_than_max() - { - var ms = new MemoryStream(); - var writer = new H2FrameWriter(ms); - await writer.WriteDataAsync(1, new byte[100], endStream: false, CancellationToken.None); - - ms.Position = 0; - var reader = new H2FrameReader(ms, maxFrameSize: 50); - var act = async () => await reader.ReadFrameAsync(CancellationToken.None); - await act.Should().ThrowAsync(); - } -} diff --git a/tests/Starling.Net.Tests/Http/H2/HpackTests.cs b/tests/Starling.Net.Tests/Http/H2/HpackTests.cs deleted file mode 100644 index d993143a..00000000 --- a/tests/Starling.Net.Tests/Http/H2/HpackTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Text; -using AwesomeAssertions; -using Starling.Net.Http.H2.Hpack; - -namespace Starling.Net.Tests.Http.H2; - -/// -/// HPACK conformance tests (RFC 7541). Integer and request examples use the -/// exact byte vectors from Appendix C; the encoder is exercised by round-trip. -/// -[TestClass] -public class HpackTests -{ - private static byte[] Hex(string hex) - { - hex = hex.Replace(" ", "", StringComparison.Ordinal); - var bytes = new byte[hex.Length / 2]; - for (var i = 0; i < bytes.Length; i++) - { - bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); - } - - return bytes; - } - - [TestMethod] - public void Integer_encodes_per_appendix_C1() - { - Span buf = stackalloc byte[8]; - - // C.1.1: 10 with a 5-bit prefix. - HpackInteger.Encode(buf, 10, 5, 0).Should().Be(1); - buf[0].Should().Be(0x0a); - - // C.1.2: 1337 with a 5-bit prefix. - var n = HpackInteger.Encode(buf, 1337, 5, 0); - buf[..n].ToArray().Should().Equal(0x1f, 0x9a, 0x0a); - - // C.1.3: 42 at an octet boundary (8-bit prefix). - HpackInteger.Encode(buf, 42, 8, 0).Should().Be(1); - buf[0].Should().Be(0x2a); - } - - [TestMethod] - public void Integer_round_trips() - { - Span buf = stackalloc byte[8]; - foreach (var value in new[] { 0, 1, 30, 31, 32, 127, 128, 1337, 16_383, 1_000_000, int.MaxValue }) - { - var n = HpackInteger.Encode(buf, value, 5, 0); - var offset = 0; - HpackInteger.TryDecode(buf, ref offset, 5, out var decoded).Should().BeTrue(); - decoded.Should().Be(value); - offset.Should().Be(n); - } - } - - [TestMethod] - public void Huffman_decodes_appendix_C4_authority() - { - // The Huffman-coded value of "www.example.com" from C.4.1. - var encoded = Hex("f1e3 c2e5 f23a 6ba0 ab90 f4ff"); - HpackHuffman.TryDecode(encoded, out var decoded).Should().BeTrue(); - Encoding.ASCII.GetString(decoded).Should().Be("www.example.com"); - } - - [TestMethod] - public void Huffman_round_trips_arbitrary_text() - { - foreach (var text in new[] { "", "a", "GET", "/index.html?q=1", "Mon, 21 Oct 2013 20:13:21 GMT" }) - { - var src = Encoding.ASCII.GetBytes(text); - var buf = new byte[HpackHuffman.EncodedLength(src)]; - var n = HpackHuffman.Encode(src, buf); - n.Should().Be(buf.Length); - HpackHuffman.TryDecode(buf, out var back).Should().BeTrue(); - Encoding.ASCII.GetString(back).Should().Be(text); - } - } - - [TestMethod] - public void Huffman_rejects_oversized_padding() - { - // A whole 0xff byte is 8 padding bits — padding must be < 8 bits. - HpackHuffman.TryDecode(new byte[] { 0xff }, out _).Should().BeFalse(); - } - - [TestMethod] - public void Decoder_decodes_appendix_C41_request_with_huffman() - { - // C.4.1 First Request: indexed fields + literal-indexed name + Huffman value. - var block = Hex("8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 ff"); - var decoder = new HpackDecoder(4096); - - decoder.TryDecode(block, out var fields).Should().BeTrue(); - - fields.Select(f => (f.Name, f.Value)).Should().Equal( - (":method", "GET"), - (":scheme", "http"), - (":path", "/"), - (":authority", "www.example.com")); - } - - [TestMethod] - public void Decoder_tracks_dynamic_table_across_two_requests() - { - // C.4.1 then C.4.2: the second request references the dynamic-table - // entry inserted by the first, so a single decoder must carry state. - var decoder = new HpackDecoder(4096); - - decoder.TryDecode(Hex("8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 ff"), out _).Should().BeTrue(); - - // C.4.2 Second Request. - decoder.TryDecode(Hex("8286 84be 5886 a8eb 1064 9cbf"), out var second).Should().BeTrue(); - second.Select(f => (f.Name, f.Value)).Should().Equal( - (":method", "GET"), - (":scheme", "http"), - (":path", "/"), - (":authority", "www.example.com"), - ("cache-control", "no-cache")); - } - - [TestMethod] - public void Encoder_output_round_trips_through_decoder() - { - var encoder = new HpackEncoder(); - var request = new (string, string)[] - { - (":method", "GET"), - (":scheme", "https"), - (":authority", "example.com"), - (":path", "/some/path?x=1"), - ("user-agent", "Starling/0.1"), - ("accept", "text/html"), - ("cookie", "sid=abc123; theme=dark"), - }; - - var block = encoder.Encode(request); - - var decoder = new HpackDecoder(4096); - decoder.TryDecode(block, out var fields).Should().BeTrue(); - fields.Select(f => (f.Name, f.Value)).Should().Equal(request); - } - - [TestMethod] - public void Encoder_uses_static_index_for_exact_match() - { - // ":method: GET" is static entry 2 → a single indexed byte 0x82. - var block = new HpackEncoder().Encode([(":method", "GET")]); - block.Should().Equal(0x82); - } - - [TestMethod] - public void Decoder_rejects_zero_index() - { - // An indexed header field with index 0 is a decoding error (§6.1). - new HpackDecoder(4096).TryDecode(new byte[] { 0x80 }, out _).Should().BeFalse(); - } -} diff --git a/tests/Starling.Net.Tests/Http/StarlingHttpClientTests.cs b/tests/Starling.Net.Tests/Http/StarlingHttpClientTests.cs index f24ec228..ab2adb25 100644 --- a/tests/Starling.Net.Tests/Http/StarlingHttpClientTests.cs +++ b/tests/Starling.Net.Tests/Http/StarlingHttpClientTests.cs @@ -18,7 +18,9 @@ public async Task End_to_end_GET_against_a_local_HTTP_server_returns_200() { req.Should().StartWith("GET /test?x=1 HTTP/1.1\r\n"); req.Should().Contain("Host: localhost:"); - req.Should().Contain("Accept-Encoding: gzip, br, deflate"); + // HttpClient's SocketsHttpHandler picks the Accept-Encoding value + // (AutomaticDecompression is on); we only assert it is advertised. + req.Should().Contain("Accept-Encoding:"); return BuildResponse(body, "text/html; charset=utf-8"); }); @@ -91,6 +93,31 @@ public async Task End_to_end_GET_decodes_chunked_response() Encoding.UTF8.GetString(result.Value.Body.Span).Should().Be("hello world!"); } + [TestMethod] + public async Task Duplicate_Set_Cookie_response_headers_are_preserved() + { + using var server = await StubHttpServer.StartAsync(_ => + { + var head = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Set-Cookie: a=1\r\n" + + "Set-Cookie: b=2; Path=/\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n\r\n"; + return Encoding.ASCII.GetBytes(head); + }); + + using var client = new StarlingHttpClient(); + var result = await client.GetAsync( + $"http://localhost:{server.Port}/", CancellationToken.None); + + result.IsOk.Should().BeTrue(); + var setCookies = result.Value.Headers.GetAll("Set-Cookie"); + setCookies.Should().HaveCount(2); + setCookies.Should().Contain("a=1").And.Contain("b=2; Path=/"); + } + [TestMethod] public async Task Returns_UnsupportedScheme_for_file_url() { diff --git a/tests/Starling.Net.Tests/Tcp/TcpDialerTests.cs b/tests/Starling.Net.Tests/Tcp/TcpDialerTests.cs deleted file mode 100644 index 30603b23..00000000 --- a/tests/Starling.Net.Tests/Tcp/TcpDialerTests.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading.Channels; -using AwesomeAssertions; -using Starling.Net.Dns; -using Starling.Net.Tcp; -namespace Starling.Net.Tests.Tcp; - -[TestClass] -public class TcpDialerTests -{ - [TestMethod] - public void TcpEndpoint_validates_port_range() - { - var act1 = () => TcpEndpoint.For("a", 0); - var act2 = () => TcpEndpoint.For("a", 65536); - act1.Should().Throw(); - act2.Should().Throw(); - } - - [TestMethod] - public void TcpEndpoint_rejects_empty_hostname() - { - var act = () => TcpEndpoint.For(" ", 80); - act.Should().Throw(); - } - - [TestMethod] - public async Task Direct_dial_round_trips_bytes_through_a_local_listener() - { - using var listener = new EchoListener(); - var resolver = new DnsResolver(new NoopTransport()); - var dialer = new TcpDialer(resolver) { ConnectTimeout = TimeSpan.FromSeconds(2) }; - - var ct = CancellationToken.None; - var dialResult = await dialer.DialDirectAsync( - listener.LocalEndpoint, - TcpEndpoint.For("localhost", listener.LocalEndpoint.Port), - ct); - dialResult.IsOk.Should().BeTrue(); - - await using var conn = dialResult.Value; - conn.IsOpen.Should().BeTrue(); - conn.Endpoint.Hostname.Should().Be("localhost"); - - var payload = Encoding.UTF8.GetBytes("hello, starling tcp\n"); - await conn.WriteAsync(payload, ct); - - var buf = new byte[payload.Length]; - var total = 0; - while (total < buf.Length) - { - var n = await conn.ReadAsync(buf.AsMemory(total), ct); - if (n == 0) - { - break; - } - - total += n; - } - total.Should().Be(payload.Length); - Encoding.UTF8.GetString(buf, 0, total).Should().Be("hello, starling tcp\n"); - } - - [TestMethod] - public async Task Connect_to_unbound_port_returns_ConnectFailed() - { - // 127.0.0.1 with a very-likely-unbound port; if a system listener - // happens to be there we tolerate the spurious pass by checking only - // that the result type is well-formed. - var resolver = new DnsResolver(new NoopTransport()); - var dialer = new TcpDialer(resolver) { ConnectTimeout = TimeSpan.FromMilliseconds(500) }; - var ct = CancellationToken.None; - var r = await dialer.DialDirectAsync( - new IPEndPoint(IPAddress.Loopback, 1), - TcpEndpoint.For("localhost", 1), ct); - r.IsErr.Should().BeTrue(); - r.Error.Should().Be(TcpError.ConnectFailed); - } - - [TestMethod] - public async Task Connection_disposes_cleanly() - { - using var listener = new EchoListener(); - var dialer = new TcpDialer(new DnsResolver(new NoopTransport())); - var ct = CancellationToken.None; - var dial = await dialer.DialDirectAsync( - listener.LocalEndpoint, TcpEndpoint.For("localhost", listener.LocalEndpoint.Port), ct); - - var conn = dial.Value; - await conn.ShutdownAsync(ct); - conn.IsOpen.Should().BeFalse(); - await conn.DisposeAsync(); - } - - [TestMethod] - public async Task DisposeAsync_completes_synchronously_when_called_from_synchronization_context() - { - using var listener = new EchoListener(); - using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - await client.ConnectAsync(listener.LocalEndpoint, CancellationToken.None); - - var conn = new SocketTcpConnection( - client, - TcpEndpoint.For("localhost", listener.LocalEndpoint.Port)); - var originalContext = SynchronizationContext.Current; - var context = new QueuingSynchronizationContext(); - Task disposeTask; - bool completedSynchronously; - - try - { - SynchronizationContext.SetSynchronizationContext(context); - disposeTask = conn.DisposeAsync().AsTask(); - completedSynchronously = disposeTask.IsCompletedSuccessfully; - } - finally - { - SynchronizationContext.SetSynchronizationContext(originalContext); - } - - if (!completedSynchronously) - { - await context.DrainAsync(); - await disposeTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); - } - - completedSynchronously.Should().BeTrue( - "synchronous dispose paths must not post continuations back to UI synchronization contexts"); - } - - [TestMethod] - public async Task Read_returns_zero_when_peer_closes() - { - using var listener = new EchoListener(closeAfterFirstByte: true); - var dialer = new TcpDialer(new DnsResolver(new NoopTransport())); - var ct = CancellationToken.None; - var dial = await dialer.DialDirectAsync( - listener.LocalEndpoint, TcpEndpoint.For("localhost", listener.LocalEndpoint.Port), ct); - await using var conn = dial.Value; - - await conn.WriteAsync(new byte[] { (byte)'x' }, ct); - var buf = new byte[16]; - var first = await conn.ReadAsync(buf.AsMemory(0, 1), ct); - first.Should().Be(1); - - // Listener closes after echoing one byte → next read should return 0. - var next = await conn.ReadAsync(buf, ct); - next.Should().Be(0); - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - /// Minimal echo TCP server bound to an ephemeral loopback port. - private sealed class EchoListener : IDisposable - { - private readonly TcpListener _listener; - private readonly CancellationTokenSource _cts = new(); - - public IPEndPoint LocalEndpoint { get; } - - public EchoListener(bool closeAfterFirstByte = false) - { - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - LocalEndpoint = (IPEndPoint)_listener.LocalEndpoint; - _ = Task.Run(() => AcceptLoop(closeAfterFirstByte)); - } - - private async Task AcceptLoop(bool closeAfterFirstByte) - { - try - { - while (!_cts.IsCancellationRequested) - { - var client = await _listener.AcceptTcpClientAsync(_cts.Token); - _ = Task.Run(() => Echo(client, closeAfterFirstByte)); - } - } - catch { /* shutting down */ } - } - - private static async Task Echo(TcpClient client, bool closeAfterFirstByte) - { - try - { - using (client) - using (var s = client.GetStream()) - { - var buf = new byte[4096]; - while (true) - { - var n = await s.ReadAsync(buf); - if (n == 0) - { - break; - } - - await s.WriteAsync(buf.AsMemory(0, n)); - if (closeAfterFirstByte) - { - break; - } - } - } - } - catch { /* drop */ } - } - - public void Dispose() - { - _cts.Cancel(); - _listener.Stop(); - _cts.Dispose(); - } - } - - private sealed class NoopTransport : IDnsTransport - { - public Task SendAsync(byte[] queryPacket, CancellationToken ct) - => throw new InvalidOperationException("DNS not exercised in this test"); - } - - private sealed class QueuingSynchronizationContext : SynchronizationContext - { - private readonly Channel<(SendOrPostCallback Callback, object? State)> _callbacks = - Channel.CreateUnbounded<(SendOrPostCallback, object?)>(); - - public override void Post(SendOrPostCallback d, object? state) - => _callbacks.Writer.TryWrite((d, state)); - - public async Task DrainAsync() - { - _callbacks.Writer.Complete(); - await foreach (var (callback, state) in _callbacks.Reader.ReadAllAsync()) - { - callback(state); - } - } - } -} diff --git a/tests/Starling.Net.Tests/Tls/CertificateVerifierTests.cs b/tests/Starling.Net.Tests/Tls/CertificateVerifierTests.cs new file mode 100644 index 00000000..848a9e62 --- /dev/null +++ b/tests/Starling.Net.Tests/Tls/CertificateVerifierTests.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using AwesomeAssertions; +using Starling.Net.Tls; + +namespace Starling.Net.Tests.Tls; + +[TestClass] +public class CertificateVerifierTests +{ + [TestMethod] + public void Accepts_a_leaf_that_chains_to_a_bundled_root_and_matches_the_host() + { + var (root, leaf) = BuildChain("CN=Test Root", "leaf.example"); + var roots = RootsOf(root); + + CertificateVerifier.Verify(leaf, null, "leaf.example", roots) + .Should().BeTrue(); + } + + [TestMethod] + public void Rejects_a_host_the_leaf_does_not_cover() + { + var (root, leaf) = BuildChain("CN=Test Root", "leaf.example"); + var roots = RootsOf(root); + + CertificateVerifier.Verify(leaf, null, "other.example", roots) + .Should().BeFalse(); + } + + [TestMethod] + public void Rejects_a_leaf_that_chains_to_an_untrusted_root() + { + var (_, leaf) = BuildChain("CN=Real Root", "leaf.example"); + var (otherRoot, _) = BuildChain("CN=Other Root", "unrelated.example"); + var roots = RootsOf(otherRoot); + + CertificateVerifier.Verify(leaf, null, "leaf.example", roots) + .Should().BeFalse(); + } + + private static RootCertificates RootsOf(X509Certificate2 root) + { + var pem = Encoding.ASCII.GetBytes(root.ExportCertificatePem()); + return RootCertificates.FromPem(new MemoryStream(pem)); + } + + private static (X509Certificate2 Root, X509Certificate2 Leaf) BuildChain(string rootSubject, string leafHost) + { + var notBefore = DateTimeOffset.UtcNow.AddDays(-1); + var notAfter = DateTimeOffset.UtcNow.AddDays(1); + + using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var caRequest = new CertificateRequest(rootSubject, caKey, HashAlgorithmName.SHA256); + caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + var root = caRequest.CreateSelfSigned(notBefore, notAfter); + + using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var leafRequest = new CertificateRequest($"CN={leafHost}", leafKey, HashAlgorithmName.SHA256); + leafRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName(leafHost); + leafRequest.CertificateExtensions.Add(sanBuilder.Build()); + + var serial = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var leaf = leafRequest.Create(root, notBefore, notAfter, serial); + return (root, leaf); + } +} diff --git a/tests/Starling.Net.Tests/Tls/TlsClientTests.cs b/tests/Starling.Net.Tests/Tls/TlsClientTests.cs deleted file mode 100644 index f6f29fb7..00000000 --- a/tests/Starling.Net.Tests/Tls/TlsClientTests.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System.Net; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using AwesomeAssertions; -using Org.BouncyCastle.Security; -using Org.BouncyCastle.Tls; -using Org.BouncyCastle.Tls.Crypto.Impl.BC; -using Starling.Net.Tcp; -using Starling.Net.Tls; -using BcCertificate = Org.BouncyCastle.Tls.Certificate; -using DotNetX509Certificate = System.Security.Cryptography.X509Certificates.X509Certificate2; - -namespace Starling.Net.Tests.Tls; - -[TestClass] -public class TlsClientTests -{ - [TestMethod] - public void Default_root_store_loads_embedded_ccadb_bundle() - { - RootCertificates.Default.Certificates.Count.Should().BeGreaterThan(50); - } - - [TestMethod] - public void System_trust_is_a_superset_of_the_embedded_bundle() - { - // SystemTrust augments the embedded floor with OS-trusted roots. It can - // never have fewer anchors than the bundle, and on a machine with a - // populated Root store it has strictly more. - RootCertificates.SystemTrust.Certificates.Count - .Should().BeGreaterThanOrEqualTo(RootCertificates.Default.Certificates.Count); - } - - [TestMethod] - public void Client_extensions_advertise_sni_and_alpn() - { - var options = TlsClientOptions.ForHttps("example.com"); - var client = new StarlingTlsClient( - new BcTlsCrypto(new SecureRandom()), - options, - RootCertificates.Default); - - var extensions = client.CreateClientExtensionsForTesting(); - - var names = TlsExtensionsUtilities.GetServerNameExtensionClient(extensions); - names.Should().ContainSingle(); - names[0].NameType.Should().Be(NameType.host_name); - names[0].NameData.Should().Equal("example.com"u8.ToArray()); - - var alpn = TlsExtensionsUtilities.GetAlpnExtensionClient(extensions) - .Select(protocol => protocol.GetUtf8Decoding()) - .ToArray(); - alpn.Should().Equal("h2", "http/1.1"); - } - - [TestMethod] - [DataRow("example.com", "example.com", true)] - [DataRow("EXAMPLE.com.", "example.com", true)] - [DataRow("*.example.com", "www.example.com", true)] - [DataRow("*.example.com", "deep.www.example.com", false)] - [DataRow("*.example.com", "example.com", false)] - [DataRow("*.*.example.com", "www.example.com", false)] - public void Dns_name_matching_handles_rfc6125_wildcard_shape( - string pattern, - string hostname, - bool expected) => - CertificateHostNameMatcher.MatchDnsName(pattern, hostname).Should().Be(expected); - - [TestMethod] - public async Task Invalid_options_return_error_before_handshake() - { - var result = await BcTlsTransport.ConnectAsync( - new ClosedConnection(), - new TlsClientOptions("", []), - CancellationToken.None); - - result.IsErr.Should().BeTrue(); - result.Error.Should().Be(TlsError.InvalidOptions); - } - - [TestMethod] - public void Untrusted_self_signed_certificate_chain_is_rejected() - { - using var rsa = RSA.Create(2048); - var request = new System.Security.Cryptography.X509Certificates.CertificateRequest( - "CN=bad.example", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - var san = new SubjectAlternativeNameBuilder(); - san.AddDnsName("bad.example"); - request.CertificateExtensions.Add(san.Build()); - using DotNetX509Certificate certificate = request.CreateSelfSigned( - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddDays(1)); - - var tlsCertificate = new BcTlsCrypto(new SecureRandom()) - .CreateCertificate(certificate.Export(X509ContentType.Cert)); - var chain = new BcCertificate([tlsCertificate]); - - CertificateVerifier.Verify(chain, "bad.example", RootCertificates.Default) - .Should().BeFalse(); - } - - [TestMethod] - public void Chain_is_trusted_when_anchor_precedes_an_untrusted_cross_sign_root() - { - // Mirrors angular.dev (Google Trust Services): the server presents - // leaf -> intermediate -> trusted root (cross-signed form) -> legacy root - // where the trusted root is in our bundle but the trailing legacy root, - // which cross-signed it, is not. The anchor sits at chain[^2], so a - // verifier that only checks the terminal cert would wrongly reject this. - var notBefore = DateTimeOffset.UtcNow.AddDays(-1); - var notAfter = DateTimeOffset.UtcNow.AddDays(1); - - using var legacyKey = RSA.Create(2048); - var legacyRoot = CaRequest("CN=Legacy Cross Root", legacyKey) - .CreateSelfSigned(notBefore, notAfter); - - using var trustedKey = RSA.Create(2048); - var trustedRequest = CaRequest("CN=Trusted Root", trustedKey); - // Self-signed form goes in our store; cross-signed form (same subject and - // key, issued by the legacy root) is what the server actually presents. - using DotNetX509Certificate trustedSelfSigned = - trustedRequest.CreateSelfSigned(notBefore, notAfter); - using DotNetX509Certificate trustedCrossSigned = trustedRequest.Create( - legacyRoot.SubjectName, - X509SignatureGenerator.CreateForRSA(legacyKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x01, 0x02, 0x03, 0x04]); - - using var intermediateKey = RSA.Create(2048); - using DotNetX509Certificate intermediate = CaRequest("CN=Intermediate", intermediateKey).Create( - trustedSelfSigned.SubjectName, - X509SignatureGenerator.CreateForRSA(trustedKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x02, 0x03, 0x04, 0x05]); - - using var leafKey = RSA.Create(2048); - var leafRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest( - "CN=leaf.example", - leafKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - var leafSan = new SubjectAlternativeNameBuilder(); - leafSan.AddDnsName("leaf.example"); - leafRequest.CertificateExtensions.Add(leafSan.Build()); - using DotNetX509Certificate leaf = leafRequest.Create( - intermediate.SubjectName, - X509SignatureGenerator.CreateForRSA(intermediateKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x03, 0x04, 0x05, 0x06]); - - var crypto = new BcTlsCrypto(new SecureRandom()); - var presented = new BcCertificate( - [ - crypto.CreateCertificate(leaf.Export(X509ContentType.Cert)), - crypto.CreateCertificate(intermediate.Export(X509ContentType.Cert)), - crypto.CreateCertificate(trustedCrossSigned.Export(X509ContentType.Cert)), - crypto.CreateCertificate(legacyRoot.Export(X509ContentType.Cert)), - ]); - - using var store = new MemoryStream( - System.Text.Encoding.ASCII.GetBytes(trustedSelfSigned.ExportCertificatePem())); - var roots = RootCertificates.FromPem(store); - - CertificateVerifier.Verify(presented, "leaf.example", roots) - .Should().BeTrue(); - } - - [TestMethod] - public void Leaf_signed_by_a_non_ca_intermediate_is_rejected() - { - // PKIX enforces basic constraints: an end-entity cert (CA=false) may not - // issue other certs. The chain links and signatures are all valid and it - // terminates at a trusted root, so the previous hand-rolled verifier - // accepted it — this is the security gap the PKIX validator closes. - var notBefore = DateTimeOffset.UtcNow.AddDays(-1); - var notAfter = DateTimeOffset.UtcNow.AddDays(1); - - using var rootKey = RSA.Create(2048); - using DotNetX509Certificate root = CaRequest("CN=Test Root", rootKey) - .CreateSelfSigned(notBefore, notAfter); - - using var fakeKey = RSA.Create(2048); - var fakeRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest( - "CN=Not A CA", - fakeKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - fakeRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); - using DotNetX509Certificate fakeIntermediate = fakeRequest.Create( - root.SubjectName, - X509SignatureGenerator.CreateForRSA(rootKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x01, 0x02, 0x03, 0x07]); - - using var leafKey = RSA.Create(2048); - var leafRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest( - "CN=leaf.example", - leafKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - var leafSan = new SubjectAlternativeNameBuilder(); - leafSan.AddDnsName("leaf.example"); - leafRequest.CertificateExtensions.Add(leafSan.Build()); - using DotNetX509Certificate leaf = leafRequest.Create( - fakeIntermediate.SubjectName, - X509SignatureGenerator.CreateForRSA(fakeKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x01, 0x02, 0x03, 0x08]); - - var crypto = new BcTlsCrypto(new SecureRandom()); - var presented = new BcCertificate( - [ - crypto.CreateCertificate(leaf.Export(X509ContentType.Cert)), - crypto.CreateCertificate(fakeIntermediate.Export(X509ContentType.Cert)), - ]); - - using var store = new MemoryStream( - System.Text.Encoding.ASCII.GetBytes(root.ExportCertificatePem())); - var roots = RootCertificates.FromPem(store); - - CertificateVerifier.Verify(presented, "leaf.example", roots) - .Should().BeFalse(); - } - - [TestMethod] - public void Revoked_leaf_serial_is_rejected_but_an_empty_blocklist_accepts() - { - var (presented, roots, leaf, intermediate) = BuildTrustedLeafChain(); - - // Empty blocklist: the chain is otherwise valid, so it verifies. - CertificateVerifier.Verify(presented, "leaf.example", roots, null, RevocationSet.Empty) - .Should().BeTrue(); - - // Revoke the leaf by (issuer SPKI, serial): now it must be rejected. - var revoked = RevocationSet.Create( - [], - [(RevocationSet.SpkiHash(intermediate), RevocationSet.SerialHex(leaf))]); - CertificateVerifier.Verify(presented, "leaf.example", roots, null, revoked) - .Should().BeFalse(); - } - - [TestMethod] - public void Blocked_intermediate_spki_is_rejected() - { - var (presented, roots, _, intermediate) = BuildTrustedLeafChain(); - - // Distrust the intermediate's key outright — the whole path falls. - var blocked = RevocationSet.Create([RevocationSet.SpkiHash(intermediate)], []); - CertificateVerifier.Verify(presented, "leaf.example", roots, null, blocked) - .Should().BeFalse(); - } - - [TestMethod] - public void Revocation_text_format_round_trips() - { - const string text = """ - # sample blocklist - spki AABBCC - - serial DEADBEEF 01ff - """; - using var stream = new MemoryStream(System.Text.Encoding.ASCII.GetBytes(text)); - var set = RevocationSet.FromText(stream); - - set.IsEmpty.Should().BeFalse(); - // Hex is normalized to upper-case; a malformed line would have thrown. - var equivalent = RevocationSet.Create(["aabbcc"], [("deadbeef", "01FF")]); - equivalent.IsEmpty.Should().BeFalse(); - } - - // A valid leaf -> intermediate -> trusted root chain, with the root in the - // returned store. Returns the presented chain plus the leaf and intermediate - // so tests can target them for revocation. - private static (BcCertificate Presented, RootCertificates Roots, - Org.BouncyCastle.X509.X509Certificate Leaf, - Org.BouncyCastle.X509.X509Certificate Intermediate) BuildTrustedLeafChain() - { - var notBefore = DateTimeOffset.UtcNow.AddDays(-1); - var notAfter = DateTimeOffset.UtcNow.AddDays(1); - - using var rootKey = RSA.Create(2048); - using DotNetX509Certificate root = CaRequest("CN=Test Root", rootKey) - .CreateSelfSigned(notBefore, notAfter); - - using var intermediateKey = RSA.Create(2048); - using DotNetX509Certificate intermediate = CaRequest("CN=Intermediate", intermediateKey).Create( - root.SubjectName, - X509SignatureGenerator.CreateForRSA(rootKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x0a, 0x0b, 0x0c, 0x0d]); - - using var leafKey = RSA.Create(2048); - var leafRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest( - "CN=leaf.example", - leafKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - var leafSan = new SubjectAlternativeNameBuilder(); - leafSan.AddDnsName("leaf.example"); - leafRequest.CertificateExtensions.Add(leafSan.Build()); - using DotNetX509Certificate leaf = leafRequest.Create( - intermediate.SubjectName, - X509SignatureGenerator.CreateForRSA(intermediateKey, RSASignaturePadding.Pkcs1), - notBefore, - notAfter, - [0x0e, 0x0f, 0x10, 0x11]); - - var crypto = new BcTlsCrypto(new SecureRandom()); - var presented = new BcCertificate( - [ - crypto.CreateCertificate(leaf.Export(X509ContentType.Cert)), - crypto.CreateCertificate(intermediate.Export(X509ContentType.Cert)), - ]); - - var parser = new Org.BouncyCastle.X509.X509CertificateParser(); - using var store = new MemoryStream( - System.Text.Encoding.ASCII.GetBytes(root.ExportCertificatePem())); - var roots = RootCertificates.FromPem(store); - - return ( - presented, - roots, - parser.ReadCertificate(leaf.Export(X509ContentType.Cert)), - parser.ReadCertificate(intermediate.Export(X509ContentType.Cert))); - } - - private static System.Security.Cryptography.X509Certificates.CertificateRequest CaRequest( - string subject, - RSA key) - { - var request = new System.Security.Cryptography.X509Certificates.CertificateRequest( - subject, - key, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - return request; - } - - [TestMethod] - [DataRow("cloudflare.com")] - [DataRow("tls13.akamai.io")] - public async Task Live_tls13_handshake_when_enabled(string host) - { - if (Environment.GetEnvironmentVariable("STARLING_LIVE_TLS_TESTS") != "1") - { - return; - } - - var ct = CancellationToken.None; - var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct); - var endpoint = new IPEndPoint(addresses.First(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork), 443); - var dialer = new TcpDialer(new Starling.Net.Dns.DnsResolver(new NoopDnsTransport())) - { - ConnectTimeout = TimeSpan.FromSeconds(10), - }; - var tcp = await dialer.DialDirectAsync(endpoint, TcpEndpoint.For(host, 443), ct); - tcp.IsOk.Should().BeTrue(); - - await using var connection = tcp.Value; - var tls = await BcTlsTransport.ConnectAsync( - connection, - TlsClientOptions.ForHttps(host), - ct); - - tls.IsOk.Should().BeTrue(); - tls.Value.NegotiatedApplicationProtocol.Should().BeOneOf("h2", "http/1.1", null); - tls.Value.Dispose(); - } - - private sealed class ClosedConnection : ITcpConnection - { - public TcpEndpoint Endpoint => TcpEndpoint.For("closed.example", 443); - public bool IsOpen => false; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - public ValueTask ReadAsync(Memory buffer, CancellationToken ct) => ValueTask.FromResult(0); - public ValueTask ShutdownAsync(CancellationToken ct) => ValueTask.CompletedTask; - public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken ct) => - throw new InvalidOperationException("closed"); - } - - private sealed class NoopDnsTransport : Starling.Net.Dns.IDnsTransport - { - public Task SendAsync(byte[] queryPacket, CancellationToken ct) => - throw new InvalidOperationException("DNS is not exercised by this test"); - } -}