diff --git a/src/Glyph3/Glyph3.csproj b/src/Glyph3/Glyph3.csproj index 52adf65..475cfeb 100644 --- a/src/Glyph3/Glyph3.csproj +++ b/src/Glyph3/Glyph3.csproj @@ -12,9 +12,9 @@ 13.0 Glyph3 - 0.11.652.0 - 0.11.652.0 - 0.11.652 + 0.12.0.0 + 0.12.0.0 + 0.12.0 dotnet-web-stack A transport-agnostic HTTP/3 connection for .NET: frame parsing, QPACK (static table and Huffman) and request dispatch, in pure managed C# with no native dependencies. Glyph3 does no I/O - it takes stream bytes in and hands stream bytes back - so it runs over System.Net.Quic, over io_uring, or over a pair of in-memory queues in a test. Buffered and streamed request bodies, and responses written through an IBufferWriter. MIT diff --git a/src/Glyph3/Qpack/Qpack.cs b/src/Glyph3/Qpack/Qpack.cs index e0971ad..01b026e 100644 --- a/src/Glyph3/Qpack/Qpack.cs +++ b/src/Glyph3/Qpack/Qpack.cs @@ -3,11 +3,11 @@ namespace Glyph3; /// -/// QPACK (RFC 9204) without the dynamic table: our SETTINGS advertise capacity 0, so a conforming -/// peer may only send static-table references and literals - which reduces the decoder to the -/// static table, prefixed integers, and Huffman. The encoder mirrors that: indexed :status where -/// the table has the code, literal name references otherwise, literal names for everything else, -/// no Huffman on output (legal, and keeps the writer trivial). +/// QPACK (RFC 9204). The decoder resolves static references, prefixed integers and Huffman, plus +/// dynamic references when a table is configured. The encoder prefers the static table for every +/// field, not just :status: an entry matching both name and value costs one byte, a known name +/// costs an index plus the literal value, and only an unknown name is spelled out. No Huffman on +/// output - legal, and it keeps the writer trivial. /// internal static class Qpack { @@ -405,7 +405,7 @@ private static byte[] EncodeStaticOnlyResponseFields(Http3Response response, out foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in response.Headers) { - w += WriteLiteralHeader(buf.AsSpan(w), name.Span, value.Span); + w += WriteHeader(buf.AsSpan(w), name.Span, value.Span); } written = w; @@ -445,6 +445,141 @@ private static int WriteStatus(Span buf, int status) return w + dlen; } + + /// + /// The static table indexed for encoding: distinct names, each with the entries that share it. + /// + /// + /// Grouped rather than flat so a lookup compares one candidate per distinct name and rejects + /// most on length alone. Built once; the table is 99 fixed entries. + /// + private static readonly (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[] StaticNames = BuildStaticNames(); + + private static (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[] BuildStaticNames() + { + var byName = new List<(byte[] Name, int NameIndex, List<(byte[] Value, int Index)> Values)>(); + + for (int i = 0; i < QpackStatic.Table.Length; i++) + { + (byte[] name, byte[] value) = QpackStatic.Table[i]; + + int at = -1; + for (int j = 0; j < byName.Count; j++) + { + if (byName[j].Name.AsSpan().SequenceEqual(name)) + { + at = j; + break; + } + } + + if (at < 0) + { + byName.Add((name, i, [(value, i)])); + } + else + { + byName[at].Values.Add((value, i)); + } + } + + var result = new (byte[], int, (byte[], int)[])[byName.Count]; + + for (int i = 0; i < byName.Count; i++) + { + result[i] = (byName[i].Name, byName[i].NameIndex, byName[i].Values.ToArray()); + } + + return result; + } + + /// + /// Finds a header in the static table: an exact name and value match, or failing that a name. + /// + /// + /// Names are matched case-insensitively, so a caller holding HTTP's conventional capitalisation + /// still resolves - and then never writes the name at all, which is the point. Values are + /// matched exactly, because a field value is case-sensitive. + /// + private static bool TryFindStatic(ReadOnlySpan name, ReadOnlySpan value, out int exact, out int nameIndex) + { + exact = -1; + nameIndex = -1; + + foreach ((byte[] candidate, int candidateIndex, (byte[] Value, int Index)[] values) in StaticNames) + { + if (candidate.Length != name.Length || !EqualsIgnoreCase(candidate, name)) + { + continue; + } + + nameIndex = candidateIndex; + + foreach ((byte[] entryValue, int entryIndex) in values) + { + if (entryValue.AsSpan().SequenceEqual(value)) + { + exact = entryIndex; + break; + } + } + + return true; + } + + return false; + } + + private static bool EqualsIgnoreCase(ReadOnlySpan lowercase, ReadOnlySpan other) + { + for (int i = 0; i < lowercase.Length; i++) + { + byte c = other[i]; + + if (c is >= (byte)'A' and <= (byte)'Z') + { + c |= 0x20; + } + + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } + + /// + /// Writes one header, preferring the static table over spelling the name out. + /// + /// + /// Three tiers, cheapest first: both name and value in the table costs a single byte; a known + /// name costs an index plus the literal value; anything else falls back to the full literal. + /// This works against every client, unlike the dynamic table, which stays unused unless the + /// peer advertised capacity for one. + /// + private static int WriteHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value) + { + if (TryFindStatic(name, value, out int exact, out int nameIndex)) + { + if (exact >= 0) + { + // Indexed Field Line: 1 T=1 index(6+). + return WriteInt(buf, 0xC0, 6, exact); + } + + // Literal With Name Reference: 01 N=0 T=1 nameindex(4+), then H=0 value(7+). + int written = WriteInt(buf, 0x50, 4, nameIndex); + written += WriteInt(buf[written..], 0x00, 7, value.Length); + value.CopyTo(buf[written..]); + + return written + value.Length; + } + + return WriteLiteralHeader(buf, name, value); + } + /// Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+). private static int WriteLiteralHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value) { @@ -526,7 +661,7 @@ internal static byte[] EncodeResponseFields(Http3Response response, QpackEncoder continue; } - w += WriteLiteralHeader(buf.AsSpan(w), nameM.Span, valueM.Span); + w += WriteHeader(buf.AsSpan(w), nameM.Span, valueM.Span); } if (count > 0) diff --git a/tests/Glyph3.Tests/QpackStaticEncodeTests.cs b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs new file mode 100644 index 0000000..208c748 --- /dev/null +++ b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs @@ -0,0 +1,128 @@ +using System.Text; + +using Xunit; + +namespace Glyph3.Tests; + +/// +/// Response headers encoded against the static table. Checked by decoding what comes out, so an +/// index that is off by one fails here rather than on a peer. +/// +public class QpackStaticEncodeTests +{ + [Theory] + [InlineData("content-type", "text/plain", 53)] + [InlineData("content-type", "text/html; charset=utf-8", 52)] + [InlineData("content-type", "application/json", 46)] + [InlineData("accept-ranges", "bytes", 32)] + [InlineData("cache-control", "no-cache", 39)] + [InlineData("content-encoding", "gzip", 43)] + [InlineData("vary", "origin", 60)] + public void ANameAndValueBothInTheTableCostOneByte(string name, string value, int index) + { + byte[] encoded = Encode(name, value, out int written); + + // Prefix is two bytes, then :status, then this header as 1 T=1 index(6+). + Assert.Equal((byte)(0xc0 | index), encoded[written - 1]); + } + + [Theory] + [InlineData("date", "Wed, 13 Aug 2026 22:45:39 GMT")] + [InlineData("location", "/elsewhere")] + [InlineData("etag", "\"abc123\"")] + [InlineData("last-modified", "Wed, 13 Aug 2026 00:00:00 GMT")] + [InlineData("server", "GenHTTP/11.0.0.0")] + [InlineData("set-cookie", "a=b")] + [InlineData("content-type", "application/x-custom")] + public void AKnownNameWithAnUnknownValueReferencesTheNameOnly(string name, string value) + { + byte[] encoded = Encode(name, value, out int written); + + // The name must not appear on the wire at all - that is the entire point. + Assert.DoesNotContain(name, Encoding.ASCII.GetString(encoded, 0, written)); + Assert.Contains(value, Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name, value); + } + + [Theory] + [InlineData("Content-Type", "text/plain")] + [InlineData("DATE", "Wed, 13 Aug 2026 22:45:39 GMT")] + [InlineData("Cache-Control", "no-cache")] + public void ACapitalisedNameStillResolves(string name, string value) + { + // Callers hold HTTP's conventional capitalisation; matching case-insensitively means the + // name is never written, so there is nothing to lowercase. + byte[] encoded = Encode(name, value, out int written); + + Assert.DoesNotContain(name.ToLowerInvariant(), Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name.ToLowerInvariant(), value); + } + + [Theory] + [InlineData("x-custom-header", "value")] + [InlineData("x-request-id", "abc")] + public void AnUnknownNameIsStillWrittenOut(string name, string value) + { + byte[] encoded = Encode(name, value, out int written); + + Assert.Contains(name, Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name, value); + } + + [Fact] + public void AValueIsMatchedCaseSensitively() + { + // Field values are case-sensitive, so TEXT/PLAIN is not entry 53 and must be written out. + byte[] encoded = Encode("content-type", "TEXT/PLAIN", out int written); + + Assert.Contains("TEXT/PLAIN", Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, "content-type", "TEXT/PLAIN"); + } + + [Fact] + public void TheStaticTableShrinksATypicalResponse() + { + var response = new Http3Response { Status = 200 }; + Add(response, "content-type", "text/plain"); + Add(response, "accept-ranges", "bytes"); + Add(response, "vary", "origin"); + + byte[] encoded = Qpack.EncodeResponseFields(response, out int written); + + // Two prefix bytes, an indexed :status, and one byte per header. + Assert.Equal(6, written); + + Assert.True(encoded.Length >= written); + } + + private static byte[] Encode(string name, string value, out int written) + { + var response = new Http3Response { Status = 200 }; + + Add(response, name, value); + + return Qpack.EncodeResponseFields(response, out written); + } + + private static void Add(Http3Response response, string name, string value) + => response.Headers.Add((Encoding.ASCII.GetBytes(name), Encoding.ASCII.GetBytes(value))); + + private static void AssertRoundTrips(byte[] encoded, int written, string name, string value) + { + var request = new Http3Request(); + + Assert.True(Qpack.TryDecodeFieldSection(encoded.AsSpan(0, written), request)); + + // Decoded fields are ranges into an arena until this materialises them. + request.Freeze(); + + (ReadOnlyMemory Name, ReadOnlyMemory Value) field = + Assert.Single(request.Headers, h => Encoding.ASCII.GetString(h.Name.Span) == name); + + Assert.Equal(value, Encoding.ASCII.GetString(field.Value.Span)); + } +}