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"); - } -}