Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Glyph3/Glyph3.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
<LangVersion>13.0</LangVersion>

<PackageId>Glyph3</PackageId>
<AssemblyVersion>0.11.652.0</AssemblyVersion>
<FileVersion>0.11.652.0</FileVersion>
<Version>0.11.652</Version>
<AssemblyVersion>0.12.0.0</AssemblyVersion>
<FileVersion>0.12.0.0</FileVersion>
<Version>0.12.0</Version>
<Authors>dotnet-web-stack</Authors>
<Description>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.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
149 changes: 142 additions & 7 deletions src/Glyph3/Qpack/Qpack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
namespace Glyph3;

/// <summary>
/// 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.
/// </summary>
internal static class Qpack
{
Expand Down Expand Up @@ -405,7 +405,7 @@ private static byte[] EncodeStaticOnlyResponseFields(Http3Response response, out

foreach ((ReadOnlyMemory<byte> name, ReadOnlyMemory<byte> value) in response.Headers)
{
w += WriteLiteralHeader(buf.AsSpan(w), name.Span, value.Span);
w += WriteHeader(buf.AsSpan(w), name.Span, value.Span);
}

written = w;
Expand Down Expand Up @@ -445,6 +445,141 @@ private static int WriteStatus(Span<byte> buf, int status)
return w + dlen;
}


/// <summary>
/// The static table indexed for encoding: distinct names, each with the entries that share it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}

/// <summary>
/// Finds a header in the static table: an exact name and value match, or failing that a name.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static bool TryFindStatic(ReadOnlySpan<byte> name, ReadOnlySpan<byte> 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<byte> lowercase, ReadOnlySpan<byte> 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;
}

/// <summary>
/// Writes one header, preferring the static table over spelling the name out.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static int WriteHeader(Span<byte> buf, ReadOnlySpan<byte> name, ReadOnlySpan<byte> 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);
}

/// <summary>Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+).</summary>
private static int WriteLiteralHeader(Span<byte> buf, ReadOnlySpan<byte> name, ReadOnlySpan<byte> value)
{
Expand Down Expand Up @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions tests/Glyph3.Tests/QpackStaticEncodeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Text;

using Xunit;

namespace Glyph3.Tests;

/// <summary>
/// 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.
/// </summary>
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<byte> Name, ReadOnlyMemory<byte> Value) field =
Assert.Single(request.Headers, h => Encoding.ASCII.GetString(h.Name.Span) == name);

Assert.Equal(value, Encoding.ASCII.GetString(field.Value.Span));
}
}
Loading