From 068b0e1afdc08e04719e56708ca24aeff8f73440 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 26 Mar 2026 00:44:06 +0100 Subject: [PATCH 01/13] Vibe-coded HTTP/3 implementation Can Claude do it? Yes, with guidance Does it work? Apparently, at least client side Is it broken? Who knows, quite probably --- .dialyzer.ignore-warnings | 1 + lib/ankh/http.ex | 101 ++- lib/ankh/protocol/http3.ex | 640 ++++++++++++++++++ lib/ankh/protocol/http3/frame.ex | 375 ++++++++++ lib/ankh/protocol/http3/frame/cancel_push.ex | 33 + lib/ankh/protocol/http3/frame/data.ex | 22 + lib/ankh/protocol/http3/frame/encodable.ex | 44 ++ lib/ankh/protocol/http3/frame/go_away.ex | 33 + lib/ankh/protocol/http3/frame/headers.ex | 22 + lib/ankh/protocol/http3/frame/max_push_id.ex | 33 + lib/ankh/protocol/http3/frame/push_promise.ex | 35 + lib/ankh/protocol/http3/frame/settings.ex | 65 ++ lib/ankh/protocol/http3/qpack.ex | 540 +++++++++++++++ lib/ankh/protocol/http3/qpack/huffman.ex | 634 +++++++++++++++++ lib/ankh/protocol/http3/qpack/static_table.ex | 227 +++++++ lib/ankh/protocol/http3/qpack/table.ex | 272 ++++++++ lib/ankh/protocol/http3/stream.ex | 406 +++++++++++ lib/ankh/transport/quic.ex | 452 +++++++++++++ mix.exs | 2 + mix.lock | 3 + test/ankh/protocol/http3/frame_test.exs | 546 +++++++++++++++ test/ankh/protocol/http3/request_test.exs | 411 +++++++++++ 22 files changed, 4892 insertions(+), 5 deletions(-) create mode 100644 .dialyzer.ignore-warnings create mode 100644 lib/ankh/protocol/http3.ex create mode 100644 lib/ankh/protocol/http3/frame.ex create mode 100644 lib/ankh/protocol/http3/frame/cancel_push.ex create mode 100644 lib/ankh/protocol/http3/frame/data.ex create mode 100644 lib/ankh/protocol/http3/frame/encodable.ex create mode 100644 lib/ankh/protocol/http3/frame/go_away.ex create mode 100644 lib/ankh/protocol/http3/frame/headers.ex create mode 100644 lib/ankh/protocol/http3/frame/max_push_id.ex create mode 100644 lib/ankh/protocol/http3/frame/push_promise.ex create mode 100644 lib/ankh/protocol/http3/frame/settings.ex create mode 100644 lib/ankh/protocol/http3/qpack.ex create mode 100644 lib/ankh/protocol/http3/qpack/huffman.ex create mode 100644 lib/ankh/protocol/http3/qpack/static_table.ex create mode 100644 lib/ankh/protocol/http3/qpack/table.ex create mode 100644 lib/ankh/protocol/http3/stream.ex create mode 100644 lib/ankh/transport/quic.ex create mode 100644 test/ankh/protocol/http3/frame_test.exs create mode 100644 test/ankh/protocol/http3/request_test.exs diff --git a/.dialyzer.ignore-warnings b/.dialyzer.ignore-warnings new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.dialyzer.ignore-warnings @@ -0,0 +1 @@ +[] diff --git a/lib/ankh/http.ex b/lib/ankh/http.ex index a84d238..cf2b206 100644 --- a/lib/ankh/http.ex +++ b/lib/ankh/http.ex @@ -3,12 +3,39 @@ defmodule Ankh.HTTP do HTTP public interface This module implements `Ankh` public APIs and provides protocol negotiation. + + ## Protocol negotiation for `https://` + + When connecting to an `https://` URI, the `:protocols` option controls which + HTTP version is attempted and in what order: + + * `[:h2, :"http/1.1"]` — TLS/TCP only; ALPN negotiates between HTTP/2 and + HTTP/1.1 (this is the **default**). + * `[:h3]` — QUIC only; returns an error if the QUIC handshake fails. + * `[:h3, :h2, :"http/1.1"]` — QUIC/HTTP3 is tried first; if it fails + (e.g. UDP is blocked by a firewall), the connection falls back to TLS + and ALPN negotiates between HTTP/2 and HTTP/1.1. + + HTTP/3 runs exclusively over QUIC. Use `https://` with `protocols: [:h3]` + (or `protocols: [:h3, :h2, :"http/1.1"]` for automatic fallback). + + ## Examples + + # HTTP/2 or HTTP/1.1 over TLS (default) + Ankh.HTTP.connect(URI.parse("https://example.com"), []) + + # HTTP/3 over QUIC only + Ankh.HTTP.connect(URI.parse("https://example.com"), protocols: [:h3]) + + # HTTP/3 preferred, fall back to TLS automatically + Ankh.HTTP.connect(URI.parse("https://example.com"), protocols: [:h3, :h2, :"http/1.1"]) + """ alias Ankh.{HTTP, Protocol, Transport} alias Ankh.HTTP.{Error, Request, Response} - alias Ankh.Protocol.{HTTP1, HTTP2} - alias Ankh.Transport.{TCP, TLS} + alias Ankh.Protocol.{HTTP1, HTTP2, HTTP3} + alias Ankh.Transport.{QUIC, TCP, TLS} @typedoc "HTTP body" @type body :: iodata() @@ -45,13 +72,22 @@ defmodule Ankh.HTTP do def accept(%URI{scheme: "http"} = uri, socket, options), do: Protocol.accept(%HTTP1{}, uri, %TCP{}, socket, options) - def accept(%URI{scheme: "https"} = uri, socket, options) do + def accept(%URI{scheme: "https"} = uri, socket, options) when is_port(socket) do with {:ok, negotiated_protocol} <- Transport.negotiated_protocol(%TLS{socket: socket}), {:ok, protocol} <- protocol_for_id(negotiated_protocol) do Protocol.accept(protocol, uri, %TLS{}, socket, options) end end + def accept(%URI{scheme: "https"} = uri, socket, options) when is_reference(socket) do + # QUIC connection — check negotiated ALPN and dispatch to HTTP/3. + with {:ok, negotiated_protocol} <- + Transport.negotiated_protocol(%QUIC{connection: socket}), + {:ok, protocol} <- protocol_for_id(negotiated_protocol) do + Protocol.accept(protocol, uri, %QUIC{}, socket, options) + end + end + def accept(_uri, _socket, _options), do: {:error, :unsupported_uri_scheme} @doc """ @@ -73,12 +109,65 @@ defmodule Ankh.HTTP do end def connect(%URI{scheme: "https"} = uri, options) do + {protocols, options} = Keyword.pop(options, :protocols, [:h2, :"http/1.1"]) + + if :h3 in protocols do + tls_fallback = Enum.filter(protocols, &(&1 in [:h2, :"http/1.1"])) + + case connect_quic(uri, options) do + {:ok, _} = ok -> + ok + + {:error, _} when tls_fallback != [] -> + connect_tls(uri, options, tls_fallback) + + {:error, _} = error -> + error + end + else + connect_tls(uri, options, protocols) + end + end + + def connect(_uri, _options), do: {:error, :unsupported_uri_scheme} + + # Establishes an HTTP/3 connection over QUIC. + # + # quicer's alpn() type is an Erlang string() (charlist), not a binary. + # peer_unidi_stream_count: 3 lets the server open the three unidirectional + # streams required by HTTP/3 (control, QPACK encoder, QPACK decoder). + defp connect_quic(%URI{} = uri, options) do + {timeout, options} = Keyword.pop(options, :timeout, 5_000) + + quic_options = + [alpn: [~c"h3"], verify: :verify_peer, peer_unidi_stream_count: 3] + |> Keyword.merge( + Keyword.take(options, [:cacertfile, :certfile, :keyfile, :password, :verify]) + ) + + # quicer returns a 3-tuple for transport-level failures (e.g. DNS errors, + # TLS handshake failures). Normalise to a 2-tuple so the caller's case + # clauses ({:error, _}) always see a consistent shape. + case Transport.connect(%QUIC{}, uri, timeout, quic_options) do + {:ok, transport} -> Protocol.connect(%HTTP3{}, uri, transport, options) + {:error, reason, _props} -> {:error, reason} + {:error, _} = error -> error + end + end + + # Establishes an HTTP/1.1 or HTTP/2 connection over TLS. + # + # `protocols` is a list of atoms from `[:h2, :"http/1.1"]` that are + # converted to their ALPN token strings and advertised to the server. + defp connect_tls(%URI{} = uri, options, protocols) do + alpn_tokens = Enum.map(protocols, &protocol_to_alpn_token/1) + {:ok, tls_options} = Plug.SSL.configure(cipher_suite: :strong, key: nil, cert: nil) tls_options = tls_options |> Keyword.take([:versions, :ciphers, :eccs]) - |> Keyword.merge(alpn_advertised_protocols: ["h2", "http/1.1"]) + |> Keyword.merge(alpn_advertised_protocols: alpn_tokens) {timeout, options} = options @@ -92,10 +181,12 @@ defmodule Ankh.HTTP do end end - def connect(_uri, _options), do: {:error, :unsupported_uri_scheme} + defp protocol_to_alpn_token(:h2), do: "h2" + defp protocol_to_alpn_token(:"http/1.1"), do: "http/1.1" defp protocol_for_id("http/1.1"), do: {:ok, %HTTP1{}} defp protocol_for_id("h2"), do: {:ok, %HTTP2{}} + defp protocol_for_id("h3"), do: {:ok, %HTTP3{}} defp protocol_for_id(_id), do: {:error, :unsupported_protocol_identifier} @doc """ diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex new file mode 100644 index 0000000..0b4d53b --- /dev/null +++ b/lib/ankh/protocol/http3.ex @@ -0,0 +1,640 @@ +defmodule Ankh.Protocol.HTTP3 do + @moduledoc """ + HTTP/3 protocol implementation for Ankh. + + This module implements the `Ankh.Protocol` protocol over a QUIC connection + managed by the `quicer` library. It supports both the client (`connect/4`) and + server (`accept/5`) sides of an HTTP/3 connection. + + ## Architecture + + Unlike TCP-based HTTP protocols, QUIC is inherently multiplexed. Each logical + HTTP request/response pair runs on its own QUIC stream. When operating in HTTP/3 + mode the `Ankh.Transport.QUIC` struct has `stream: nil`; raw QUIC messages are + forwarded directly to this protocol's `stream/2` callback rather than being + unwrapped by the transport layer. + + Each QUIC stream is tracked in the `streams` map keyed by the opaque quicer + stream handle. A parallel `references` map provides the reverse lookup from + Elixir request references to stream handles, allowing callers to correlate + `stream/2` response events back to the originating `request/2` or `respond/3` + call. + + ## Header compression + + All header compression and decompression is delegated to + `Ankh.Protocol.HTTP3.QPACK`. Both the send and receive QPACK contexts are + initialised in static-only mode (`max_table_capacity = 0`), which eliminates + the need for dedicated QPACK encoder and decoder streams and is declared in + the SETTINGS preface written on the control stream. + + ## HTTP/3 framing + + Frame encoding and decoding (HEADERS, DATA, SETTINGS, GOAWAY, etc.) are + delegated to `Ankh.Protocol.HTTP3.Frame`. Variable-length integer encoding + follows RFC 9000 §16. + + ## Control stream + + Both `accept/5` and `connect/4` open a unidirectional HTTP/3 control stream + immediately after establishing the connection and write the mandatory SETTINGS + preface before returning. This satisfies the RFC 9114 §6.2.1 requirement that + a SETTINGS frame MUST be the first frame sent on the control stream. + + ## Per-stream lifecycle + + Each QUIC stream's HTTP/3 state is tracked by an `Ankh.Protocol.HTTP3.Stream` + struct that models the stream lifecycle + (`idle → open → half_closed_local/remote → closed`) and accumulates incoming + bytes between message deliveries. Frame parsing runs over the accumulated buffer + and any unconsumed remainder is retained for the next delivery. + + ## Incoming message dispatch + + `stream/2` pattern-matches the raw QUIC tuple messages delivered by quicer: + + * `{:quic, data, handle, _}` — binary data; appended to the stream buffer + and parsed as HTTP/3 frames. + * `{:quic, :peer_send_shutdown, handle, _}` — remote FIN; signals + end-of-stream to the caller via an empty DATA event with `complete = true`. + * `{:quic, :stream_closed, handle, _}` — stream fully closed by both sides. + * `{:quic, :new_stream, handle, props}` — new inbound stream from the peer + (server-side). + * `{:quic, :transport_shutdown | :closed, _, _}` — connection teardown; + returns `{:error, :closed}`. + + ## References + + * RFC 9114 — HTTP/3 + * RFC 9000 — QUIC Transport Protocol + * RFC 9204 — QPACK: Field Compression for HTTP/3 + """ + + alias Ankh.{Protocol, Transport} + alias Ankh.Protocol.HTTP3.{QPACK, Stream} + alias Ankh.Transport.QUIC + + @typedoc """ + Opaque HTTP/3 protocol state struct. + + Carries the QPACK encode/decode contexts, the per-stream state maps, the + underlying QUIC transport, the connection URI, and both local and remote + SETTINGS values. + """ + @opaque t :: %__MODULE__{ + mode: :client | :server | nil, + recv_qpack: QPACK.t() | nil, + send_qpack: QPACK.t() | nil, + streams: %{optional(reference()) => Stream.t()}, + references: %{optional(reference()) => reference()}, + transport: QUIC.t() | nil, + uri: URI.t() | nil, + local_settings: keyword(), + remote_settings: keyword() + } + + defstruct mode: nil, + recv_qpack: nil, + send_qpack: nil, + streams: %{}, + references: %{}, + transport: nil, + uri: nil, + local_settings: [], + remote_settings: [] + + defimpl Protocol do + alias Ankh.HTTP.{Request, Response} + alias Ankh.Protocol.HTTP3.{Frame, QPACK, Stream} + + alias Ankh.Protocol.HTTP3.Frame.{ + CancelPush, + Data, + GoAway, + Headers, + MaxPushId, + PushPromise, + Settings + } + + alias Ankh.Transport + alias Ankh.Transport.QUIC + require Logger + + # ------------------------------------------------------------------------- + # accept/5 — server side + # ------------------------------------------------------------------------- + + @doc """ + Accepts an incoming QUIC connection and initialises the HTTP/3 server state. + + Initialises QPACK encode/decode contexts in static-only mode, opens a + unidirectional HTTP/3 control stream on the accepted connection, and writes + the mandatory SETTINGS preface (RFC 9114 §6.2.1) before returning. + + The `socket` argument must be a raw QUIC connection handle (`reference()`) + as returned by quicer after accepting a connection from a listener. + """ + def accept(%@for{} = protocol, uri, transport, socket, _options) do + with {:ok, %QUIC{connection: conn} = transport} <- Transport.new(transport, socket), + :ok <- QUIC.open_unidirectional_stream(transport, Frame.control_stream_preface()) do + Logger.debug("HTTP/3 server accepted connection: #{inspect(conn)}") + + {:ok, + %@for{ + protocol + | mode: :server, + transport: transport, + uri: uri, + recv_qpack: QPACK.new(), + send_qpack: QPACK.new() + }} + end + end + + # ------------------------------------------------------------------------- + # connect/4 — client side + # ------------------------------------------------------------------------- + + @doc """ + Initialises the HTTP/3 client state on an already-established QUIC connection. + + The QUIC connection handle must already be present in `transport.connection` + (established by the caller, e.g. via `Ankh.HTTP.connect/2`). Initialises + QPACK contexts, opens a unidirectional control stream, and sends the SETTINGS + preface before returning. + """ + def connect(%@for{} = protocol, uri, %QUIC{connection: conn} = transport, _options) do + with :ok <- QUIC.open_unidirectional_stream(transport, Frame.control_stream_preface()) do + Logger.debug("HTTP/3 client connected: #{inspect(conn)}") + + {:ok, + %@for{ + protocol + | mode: :client, + transport: transport, + uri: uri, + recv_qpack: QPACK.new(), + send_qpack: QPACK.new() + }} + end + end + + # ------------------------------------------------------------------------- + # error/1 + # ------------------------------------------------------------------------- + + @doc """ + Closes the QUIC connection, signalling a connection-level error to the peer. + """ + def error(%@for{transport: transport}) do + QUIC.close_connection(transport) + end + + # ------------------------------------------------------------------------- + # request/2 + # ------------------------------------------------------------------------- + + @doc """ + Sends an HTTP/3 request on a new bidirectional QUIC stream. + + Constructs the complete header block — pseudo-headers (`:method`, `:scheme`, + `:authority`, `:path`) in order, followed by the request's regular headers — + QPACK-encodes it, and sends it in an HTTP/3 HEADERS frame on a freshly opened + bidirectional stream. If the request body is non-empty, a DATA frame follows + and the stream is gracefully shut down (FIN). + + Returns `{:ok, updated_protocol, ref}` where `ref` is the Elixir + `reference()` that identifies this request in subsequent `stream/2` events. + """ + def request( + %@for{ + uri: %{authority: authority, scheme: scheme}, + transport: transport + } = protocol, + %Request{method: method, path: path, headers: req_headers, body: body} + ) do + pseudo_headers = [ + {":method", Atom.to_string(method)}, + {":scheme", to_string(scheme)}, + {":authority", to_string(authority)}, + {":path", path || "/"} + ] + + all_headers = pseudo_headers ++ req_headers + {hbf, send_qpack} = QPACK.encode(:store, all_headers, protocol.send_qpack) + + headers_bin = IO.iodata_to_binary(hbf) + + {:ok, _, headers_frame_data} = + Frame.encode(%Headers{payload: %Headers.Payload{hbf: headers_bin}}) + + headers_frame_bin = IO.iodata_to_binary(headers_frame_data) + + with {:ok, stream_transport} <- QUIC.open_stream(transport), + :ok <- Transport.send(stream_transport, headers_frame_bin) do + if IO.iodata_length(body) > 0 do + body_bin = IO.iodata_to_binary(body) + + {:ok, _, data_frame_data} = + Frame.encode(%Data{payload: %Data.Payload{data: body_bin}}) + + data_frame_bin = IO.iodata_to_binary(data_frame_data) + Transport.send(stream_transport, data_frame_bin) + end + + # Always FIN the send side — even for no-body requests (e.g. GET). + # Without this the server never knows the request is complete and + # will not send a response. + QUIC.shutdown_stream(stream_transport) + + handle = stream_transport.stream + stream = Stream.new_request(handle) + ref = stream.reference + + Logger.debug("HTTP/3 request sent: #{method} #{path} ref=#{inspect(ref)}") + + protocol = %@for{ + protocol + | send_qpack: send_qpack, + streams: Map.put(protocol.streams, handle, stream), + references: Map.put(protocol.references, ref, handle) + } + + {:ok, protocol, ref} + end + end + + # ------------------------------------------------------------------------- + # respond/3 + # ------------------------------------------------------------------------- + + @doc """ + Sends an HTTP/3 response on the stream identified by `request_reference`. + + Builds the `:status` pseudo-header, appends the response headers, QPACK- + encodes the full block, and sends it in a HEADERS frame. If the response body + is non-empty a DATA frame follows. If trailers are present they are + QPACK-encoded and sent as a second HEADERS frame. Finally, the stream is + gracefully shut down (FIN). + """ + def respond( + %@for{} = protocol, + request_reference, + %Response{status: status, headers: resp_headers, body: body, trailers: trailers} + ) do + with {:ok, handle, stream} <- lookup_stream(protocol, request_reference) do + pseudo_headers = [{":status", Integer.to_string(status)}] + all_headers = pseudo_headers ++ resp_headers + + {hbf, send_qpack} = QPACK.encode(:store, all_headers, protocol.send_qpack) + + headers_bin = IO.iodata_to_binary(hbf) + + {:ok, _, headers_frame_data} = + Frame.encode(%Headers{payload: %Headers.Payload{hbf: headers_bin}}) + + headers_frame_bin = IO.iodata_to_binary(headers_frame_data) + %QUIC{} = quic_transport = protocol.transport + stream_transport = %QUIC{quic_transport | stream: handle} + Transport.send(stream_transport, headers_frame_bin) + + if IO.iodata_length(body) > 0 do + body_bin = IO.iodata_to_binary(body) + + {:ok, _, data_frame_data} = + Frame.encode(%Data{payload: %Data.Payload{data: body_bin}}) + + data_frame_bin = IO.iodata_to_binary(data_frame_data) + Transport.send(stream_transport, data_frame_bin) + end + + send_qpack = + if trailers != [] do + {trailer_hbf, sq} = QPACK.encode(:store, trailers, send_qpack) + trailer_bin = IO.iodata_to_binary(trailer_hbf) + + {:ok, _, trailer_frame_data} = + Frame.encode(%Headers{payload: %Headers.Payload{hbf: trailer_bin}}) + + trailer_frame_bin = IO.iodata_to_binary(trailer_frame_data) + Transport.send(stream_transport, trailer_frame_bin) + sq + else + send_qpack + end + + QUIC.shutdown_stream(stream_transport) + + stream = Stream.local_fin(stream) + + Logger.debug("HTTP/3 response sent: #{status} ref=#{inspect(request_reference)}") + + protocol = %@for{ + protocol + | send_qpack: send_qpack, + streams: Map.put(protocol.streams, handle, stream) + } + + {:ok, protocol} + end + end + + # ------------------------------------------------------------------------- + # stream/2 — incoming QUIC message dispatcher + # ------------------------------------------------------------------------- + + @doc """ + Dispatches a raw QUIC message and returns HTTP/3 response events. + + This is the sole entry point for all incoming data and connection-level + events in HTTP/3 mode. Each clause delegates to a private helper that + returns `{:ok, protocol, responses}` or `{:error, reason}`. + + Response tuples follow the `Ankh.HTTP.response()` shape: + + * `{:headers, ref, headers, complete?}` — a decoded HEADERS frame + * `{:data, ref, data, complete?}` — a DATA frame or end-of-stream signal + * `{:error, ref, reason, true}` — a stream-level protocol error + """ + def stream(%@for{} = protocol, {:quic, data, stream_handle, _props}) + when is_binary(data) do + handle_stream_data(protocol, stream_handle, data) + end + + def stream(%@for{} = protocol, {:quic, :peer_send_shutdown, stream_handle, _props}) do + handle_peer_send_shutdown(protocol, stream_handle) + end + + def stream(%@for{} = protocol, {:quic, :stream_closed, stream_handle, _props}) do + handle_stream_closed(protocol, stream_handle) + end + + def stream(%@for{} = protocol, {:quic, :new_stream, stream_handle, props}) do + handle_new_stream(protocol, stream_handle, props) + end + + def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}) do + {:error, :closed} + end + + def stream(%@for{} = _protocol, {:quic, :closed, _conn, _props}) do + {:error, :closed} + end + + def stream(%@for{} = protocol, _msg) do + {:ok, protocol, []} + end + + # ========================================================================= + # Private helpers + # ========================================================================= + + # Resolves an Elixir request reference to its quicer stream handle and + # per-stream state struct. Returns `{:error, :unknown_reference}` when + # the reference is not present in the protocol maps. + defp lookup_stream(%@for{references: references, streams: streams}, ref) do + with {:ok, handle} <- Map.fetch(references, ref), + {:ok, stream} <- Map.fetch(streams, handle) do + {:ok, handle, stream} + else + :error -> {:error, :unknown_reference} + end + end + + # Handles incoming binary data on a QUIC stream. + # + # Retrieves (or lazily creates) the per-stream state, optionally strips the + # unidirectional stream-type byte (RFC 9114 §6.2 / RFC 9204 §4.2) from + # newly-seen streams, appends the data to the stream buffer, runs the HTTP/3 + # frame parser, and re-arms active delivery. + defp handle_stream_data(%@for{streams: streams} = protocol, stream_handle, data) do + stream = Map.get(streams, stream_handle, Stream.new_incoming(stream_handle, false)) + + # For unidirectional streams whose kind has not yet been identified, + # the very first byte of data is the stream-type byte. Strip it, resolve + # the kind, then treat the remainder as HTTP/3 frame data. + {stream, effective_data} = maybe_identify_stream_kind(stream, data) + + stream = Stream.append(stream, effective_data) + + with {:ok, protocol, responses} <- process_stream_frames(protocol, stream_handle, stream) do + %QUIC{} = quic_transport = protocol.transport + QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) + {:ok, protocol, responses} + end + end + + # Strips the stream-type byte from an unidirectional stream whose kind is not + # yet known and updates the stream kind via `Stream.identify_kind/2`. + # All other stream kinds pass data through unchanged. + defp maybe_identify_stream_kind( + %Stream{kind: :unknown_unidirectional} = stream, + <> + ) do + {Stream.identify_kind(stream, type_byte), rest} + end + + defp maybe_identify_stream_kind(stream, data), do: {stream, data} + + # Parses all complete HTTP/3 frames from the stream's accumulated buffer and + # converts them into Ankh response tuples. Any incomplete trailing bytes are + # kept in the stream buffer for the next delivery. + # Maps a wire frame-type integer to the corresponding empty frame struct, + # mirroring `frame_for_type/1` in `Ankh.Protocol.HTTP2`. Unknown type + # integers return `{:error, :not_found}`; the caller ignores them per + # RFC 9114 §9. + defp frame_for_type(0x0), do: {:ok, %Data{}} + defp frame_for_type(0x1), do: {:ok, %Headers{}} + defp frame_for_type(0x3), do: {:ok, %CancelPush{}} + defp frame_for_type(0x4), do: {:ok, %Settings{}} + defp frame_for_type(0x5), do: {:ok, %PushPromise{}} + defp frame_for_type(0x7), do: {:ok, %GoAway{}} + defp frame_for_type(0xD), do: {:ok, %MaxPushId{}} + defp frame_for_type(_), do: {:error, :not_found} + + # Parses all complete HTTP/3 frames from the stream's accumulated buffer, + # decodes each one into a typed struct via `Frame.decode/2`, and dispatches + # to `recv_frame/4`. Mirrors `process_buffer/1` in `Ankh.Protocol.HTTP2`. + defp process_stream_frames( + %@for{} = protocol, + stream_handle, + %Stream{buffer: buffer} = stream + ) do + result = + buffer + |> Frame.stream() + |> Enum.reduce_while( + {:ok, protocol, stream, []}, + fn + {rest, nil}, {:ok, p, s, responses} -> + # Partial frame — store remainder and stop. + {:halt, {:ok, p, Stream.consume_buffer(s, rest), responses}} + + {rest, {type, payload}}, {:ok, p, s, responses} -> + s = Stream.consume_buffer(s, rest) + + with {:ok, frame_struct} <- frame_for_type(type), + {:ok, frame} <- Frame.decode(frame_struct, payload), + {:ok, p, s, new_responses} <- recv_frame(p, s, stream_handle, frame) do + {:cont, {:ok, p, s, new_responses ++ responses}} + else + {:error, :not_found} -> + # Unknown frame type — RFC 9114 §9: MUST be ignored. + {:cont, {:ok, p, s, responses}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end + ) + + case result do + {:ok, %@for{} = p, s, responses} -> + p = %@for{p | streams: Map.put(p.streams, stream_handle, s)} + {:ok, p, Enum.reverse(responses)} + + {:error, _} = error -> + error + end + end + + # HEADERS frame — QPACK-decode the HBF, validate pseudo-headers, emit + # a `:headers` response event. + defp recv_frame( + %@for{recv_qpack: recv_qpack} = protocol, + stream, + _stream_handle, + %Headers{payload: %Headers.Payload{hbf: hbf}} + ) do + case QPACK.decode(hbf, recv_qpack) do + {:ok, headers, recv_qpack} -> + protocol = %@for{protocol | recv_qpack: recv_qpack} + + case validate_incoming_headers(protocol, stream, headers) do + :ok -> + stream = %{stream | recv_headers: true} + {:ok, protocol, stream, [{:headers, stream.reference, headers, false}]} + + {:error, reason} -> + {:ok, protocol, stream, [{:error, stream.reference, reason, true}]} + end + + {:error, reason} -> + {:ok, protocol, stream, [{:error, stream.reference, reason, true}]} + end + end + + # DATA frame — emit a `:data` response event with the raw body bytes. + defp recv_frame( + %@for{} = protocol, + stream, + _stream_handle, + %Data{payload: %Data.Payload{data: data}} + ) do + {:ok, protocol, stream, [{:data, stream.reference, data, false}]} + end + + # SETTINGS frame — update `remote_settings` and log; no response event. + defp recv_frame( + %@for{} = protocol, + stream, + _stream_handle, + %Settings{payload: %Settings.Payload{settings: settings}} + ) do + Logger.debug("HTTP/3 SETTINGS received: #{inspect(settings)}") + {:ok, %@for{protocol | remote_settings: settings}, stream, []} + end + + # GOAWAY frame — emit a connection-level error event (RFC 9114 §7.2.6). + defp recv_frame(%@for{} = protocol, stream, _stream_handle, %GoAway{}) do + {:ok, protocol, stream, [{:error, stream.reference, :goaway, true}]} + end + + # All other known and unknown frame types are silently ignored per + # RFC 9114 §9 ("extension frames MUST be ignored"). + defp recv_frame(%@for{} = protocol, stream, _stream_handle, _frame) do + {:ok, protocol, stream, []} + end + + # Validates pseudo-headers on the first HEADERS block of a stream. + # + # On a server-mode connection the first block carries request pseudo-headers + # (`:method`, `:scheme`, `:authority`, `:path`). On a client-mode connection + # the first block carries response pseudo-headers (`:status`). Subsequent + # HEADERS blocks on the same stream are trailers and skip pseudo-header + # validation. + defp validate_incoming_headers( + %@for{mode: :server}, + %Stream{recv_headers: false}, + headers + ) do + Request.validate_headers(headers, false) + end + + defp validate_incoming_headers( + %@for{mode: :client}, + %Stream{recv_headers: false}, + headers + ) do + Response.validate_headers(headers, false) + end + + defp validate_incoming_headers(_protocol, _stream, _headers), do: :ok + + # Handles a `:peer_send_shutdown` (remote FIN) event. + # + # Transitions the stream to `:half_closed_remote` (or `:closed` if the local + # side has already sent a FIN) and emits an empty DATA event with + # `complete = true` to signal end-of-stream to the caller. + defp handle_peer_send_shutdown(%@for{streams: streams} = protocol, stream_handle) do + case Map.get(streams, stream_handle) do + nil -> + {:ok, protocol, []} + + stream -> + stream = Stream.remote_fin(stream) + protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} + {:ok, protocol, [{:data, stream.reference, <<>>, true}]} + end + end + + # Handles a `:stream_closed` event: both transport sides are finished. + # Applies `remote_fin` and `local_fin` to drive the stream to `:closed`. + defp handle_stream_closed(%@for{streams: streams} = protocol, stream_handle) do + case Map.get(streams, stream_handle) do + nil -> + {:ok, protocol, []} + + stream -> + stream = + stream + |> Stream.remote_fin() + |> Stream.local_fin() + + protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} + {:ok, protocol, []} + end + end + + # Handles a `:new_stream` notification from quicer (server-side). + # + # Creates the per-stream state (bidirectional for request streams, + # unidirectional for control/QPACK/push streams), arms active delivery + # for the new stream, and returns with no immediate response events. + defp handle_new_stream(%@for{streams: streams} = protocol, stream_handle, props) do + unidirectional? = Map.get(props, :flags, 0) == 1 + stream = Stream.new_incoming(stream_handle, unidirectional?) + + %QUIC{} = quic_transport = protocol.transport + QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) + + Logger.debug( + "HTTP/3 new stream: handle=#{inspect(stream_handle)}, unidirectional=#{unidirectional?}" + ) + + protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} + {:ok, protocol, []} + end + end +end diff --git a/lib/ankh/protocol/http3/frame.ex b/lib/ankh/protocol/http3/frame.ex new file mode 100644 index 0000000..716e3a5 --- /dev/null +++ b/lib/ankh/protocol/http3/frame.ex @@ -0,0 +1,375 @@ +defmodule Ankh.Protocol.HTTP3.Frame do + @moduledoc """ + HTTP/3 frame codec (RFC 9114) with a struct-based API that mirrors + `Ankh.Protocol.HTTP2.Frame`. + + ## Design + + Every HTTP/3 frame type is represented as a dedicated module (e.g. + `Ankh.Protocol.HTTP3.Frame.Data`) that calls + + use Ankh.Protocol.HTTP3.Frame, type: 0xN, payload: PayloadModule + + The macro injects a struct with three fields: + + %Frame.Data{type: 0, length: 0, payload: %Frame.Data.Payload{}} + + * `type` — the integer frame type code, set at compile time. + * `length` — the payload byte count; filled in by `encode/1` and `decode/2`. + * `payload` — a struct implementing `Ankh.Protocol.HTTP3.Frame.Encodable`. + + This is intentionally analogous to `Ankh.Protocol.HTTP2.Frame`, which uses + the same `__using__` / `Encodable` / `stream` / `decode` / `encode` + structure. The main differences are: + + * HTTP/3 frames have **no flags byte** and **no stream-ID field** — those + concerns belong to the QUIC layer. + * Field lengths and frame-type codes are encoded as QUIC **variable-length + integers** (VLIs, RFC 9000 §16) rather than fixed-width fields. + + ## QUIC Variable-Length Integer (VLI) encoding + + The two most-significant bits of the first byte indicate the total length: + + | MSBs | Length | Value range | + |:----:|---------|---------------------------| + | `00` | 1 byte | 0 – 63 | + | `01` | 2 bytes | 0 – 16 383 | + | `10` | 4 bytes | 0 – 1 073 741 823 | + | `11` | 8 bytes | 0 – 4 611 686 018 427 387 903 | + + `encode_vli/1` always produces the smallest valid encoding; `decode_vli/1` + reads the two prefix bits to determine width. + + ## Wire format + + Frame { + Type (VLI) + Length (VLI) + Payload (Length bytes) + } + + `stream/1` lazily unfolds `{rest, {type_integer, payload_binary}}` pairs + from a buffer binary — identical in spirit to `HTTP2.Frame.stream/1`, which + unfolds `{rest, {length, type, id, data}}` tuples. When the buffer ends with + a partial frame a single `{data, nil}` element is emitted and the stream + then terminates (next accumulator is `<<>>`), so callers can safely use + `Enum.to_list/1` on buffers that contain only complete frames, and + `Enum.reduce_while/3` (halting on the nil token) for streaming buffers. + + `decode/2` converts a `{frame_struct, payload_binary}` pair into a fully + populated frame struct by delegating to `Encodable.decode/2`. + + `encode/1` turns a populated frame struct back into `{:ok, frame, iodata}`. + """ + + import Bitwise + + alias Ankh.Protocol.HTTP3.Frame.Encodable + + # --------------------------------------------------------------------------- + # Types + # --------------------------------------------------------------------------- + + @typedoc "Frame type code (integer on the wire)." + @type type :: non_neg_integer() + + @typedoc "Payload byte count." + @type length :: non_neg_integer() + + @typedoc "Encoded frame data." + @type data :: iodata() + + @typedoc "The generic frame struct injected by `__using__/1`." + @type t :: struct() + + # --------------------------------------------------------------------------- + # __using__ macro + # --------------------------------------------------------------------------- + + @doc """ + Injects the frame struct into the calling module. + + Options: + + * `:type` — (required) the integer HTTP/3 frame type code. + * `:payload` — (optional) the module whose struct implements + `Ankh.Protocol.HTTP3.Frame.Encodable`. Defaults to `nil`, which uses the + `Any` fallback (encodes/decodes as a no-op). + + The generated struct has fields: + + * `type` — fixed at the value given to `:type`. + * `length` — number of encoded payload bytes; set during encode/decode. + * `payload` — a struct (or `nil`) implementing `Encodable`. + + ## Example + + defmodule MyFrame do + defmodule Payload do + defstruct data: <<>> + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + def decode(%@for{} = p, bin), do: {:ok, %{p | data: bin}} + def encode(%@for{data: d}), do: {:ok, d} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x99, payload: Payload + end + + %MyFrame{type: 0x99, length: 0, payload: %MyFrame.Payload{}} + """ + @spec __using__(type: type(), payload: atom() | nil) :: Macro.t() + defmacro __using__(args) do + case Keyword.fetch(args, :type) do + {:ok, type} -> + payload = Keyword.get(args, :payload) + + quote bind_quoted: [type: type, payload: payload] do + alias Ankh.Protocol.HTTP3.Frame + + @typedoc """ + HTTP/3 frame struct. + + * `type` — wire integer for this frame type (compile-time constant). + * `length` — payload size in bytes; populated by `Frame.encode/1` and + `Frame.decode/2`. + * `payload` — decoded payload struct (implements `Frame.Encodable`), or + `nil` for frames with no payload. + """ + @type t :: %__MODULE__{ + type: Frame.type(), + length: Frame.length(), + payload: Frame.Encodable.t() | nil + } + + payload_default = if payload, do: struct(payload), else: nil + + defstruct type: type, + length: 0, + payload: payload_default + end + + :error -> + raise ArgumentError, + "#{__MODULE__}: `use Ankh.Protocol.HTTP3.Frame` requires a `:type` option" + end + end + + # --------------------------------------------------------------------------- + # stream/1 + # --------------------------------------------------------------------------- + + @doc """ + Returns a lazy stream of `{rest, frame_token}` pairs unfolded from `data`. + + Each `frame_token` is either: + + * `{type_integer, payload_binary}` — a complete frame extracted from the + buffer; `rest` is the remaining unconsumed binary. + * `nil` — the buffer ends with a partial (incomplete) frame; `rest` is the + full unconsumed binary that should be kept for the next delivery. + + The caller drives the stream with `Enum.reduce_while/3`, halting on `nil` + to store the remainder and continuing on each complete frame. + + This mirrors `Ankh.Protocol.HTTP2.Frame.stream/1`, which yields + `{rest, {length, type, id, data}}` tuples from a TCP buffer. + + ## Example + + iex> bin = + ...> IO.iodata_to_binary([ + ...> Ankh.Protocol.HTTP3.Frame.encode_vli(0x1), # HEADERS type + ...> Ankh.Protocol.HTTP3.Frame.encode_vli(3), # length + ...> "hdr", + ...> Ankh.Protocol.HTTP3.Frame.encode_vli(0x0), # DATA type + ...> Ankh.Protocol.HTTP3.Frame.encode_vli(2), # length + ...> "hi" + ...> ]) + iex> Ankh.Protocol.HTTP3.Frame.stream(bin) |> Enum.map(fn {_rest, token} -> token end) + [{1, "hdr"}, {0, "hi"}] + """ + @spec stream(binary()) :: Enumerable.t() + def stream(data) do + Stream.unfold(data, fn + <<>> -> + nil + + data -> + with {:ok, type, rest1} <- decode_vli(data), + {:ok, length, rest2} <- decode_vli(rest1), + true <- byte_size(rest2) >= length do + <> = rest2 + {{rest3, {type, payload}}, rest3} + else + # Partial frame: emit one nil token then terminate the stream so + # callers using Enum.to_list/1 don't loop forever. + _ -> {{data, nil}, <<>>} + end + end) + end + + # --------------------------------------------------------------------------- + # decode/2 + # --------------------------------------------------------------------------- + + @doc """ + Decodes `payload_binary` into the given frame struct by delegating to + `Encodable.decode/2` on the struct's `:payload` field. + + On success returns `{:ok, frame}` with `:length` set to + `byte_size(payload_binary)` and `:payload` replaced by the decoded struct. + + ## Example + + frame = %Ankh.Protocol.HTTP3.Frame.Data{} + {:ok, decoded} = Ankh.Protocol.HTTP3.Frame.decode(frame, "hello") + # decoded.payload.data == "hello" + # decoded.length == 5 + """ + @spec decode(t(), binary()) :: {:ok, t()} | {:error, any()} + def decode(frame, payload_binary) when is_binary(payload_binary) do + with {:ok, decoded_payload} <- Encodable.decode(frame.payload, payload_binary) do + {:ok, %{frame | length: byte_size(payload_binary), payload: decoded_payload}} + end + end + + # --------------------------------------------------------------------------- + # encode/1 + # --------------------------------------------------------------------------- + + @doc """ + Encodes a frame struct into its wire representation. + + Returns `{:ok, updated_frame, iodata}` where `iodata` is the complete + on-wire frame (type VLI + length VLI + payload bytes) and `updated_frame` + has its `:length` field set to the encoded payload byte count. + + ## Example + + frame = %Ankh.Protocol.HTTP3.Frame.Data{ + payload: %Ankh.Protocol.HTTP3.Frame.Data.Payload{data: "hi"} + } + {:ok, updated, iodata} = Ankh.Protocol.HTTP3.Frame.encode(frame) + # updated.length == 2 + # IO.iodata_to_binary(iodata) == <<0x00, 0x02, ?h, ?i>> + """ + @spec encode(t()) :: {:ok, t(), data()} | {:error, any()} + def encode(%{type: type, payload: payload} = frame) do + with {:ok, encoded_payload} <- Encodable.encode(payload) do + payload_bin = IO.iodata_to_binary(encoded_payload) + length = byte_size(payload_bin) + + { + :ok, + %{frame | length: length}, + [encode_vli(type), encode_vli(length), payload_bin] + } + end + end + + # --------------------------------------------------------------------------- + # VLI encode / decode + # --------------------------------------------------------------------------- + + @doc """ + Encodes a non-negative integer as a QUIC variable-length integer. + + Always selects the smallest representation that fits the value. + + ## Examples + + iex> Ankh.Protocol.HTTP3.Frame.encode_vli(0) + <<0>> + + iex> Ankh.Protocol.HTTP3.Frame.encode_vli(63) + <<63>> + + iex> Ankh.Protocol.HTTP3.Frame.encode_vli(64) + <<0x40, 64>> + + iex> Ankh.Protocol.HTTP3.Frame.encode_vli(16_383) + <<0x7F, 0xFF>> + + iex> Ankh.Protocol.HTTP3.Frame.encode_vli(494) + <<0x41, 0xEE>> + """ + @spec encode_vli(non_neg_integer()) :: binary() + def encode_vli(n) when n < 64, do: <> + def encode_vli(n) when n < 16_384, do: <> + def encode_vli(n) when n < 1_073_741_824, do: <> + def encode_vli(n), do: <> + + @doc """ + Decodes one QUIC variable-length integer from the front of `data`. + + Returns `{:ok, value, rest}` on success, or `{:error, :incomplete}` when + there are not enough bytes for the width indicated by the prefix bits. + + ## Examples + + iex> Ankh.Protocol.HTTP3.Frame.decode_vli(<<0x41, 0xEE, "rest">>) + {:ok, 494, "rest"} + + iex> Ankh.Protocol.HTTP3.Frame.decode_vli(<<7>>) + {:ok, 7, ""} + + iex> Ankh.Protocol.HTTP3.Frame.decode_vli(<<>>) + {:error, :incomplete} + + iex> Ankh.Protocol.HTTP3.Frame.decode_vli(<<0x40>>) + {:error, :incomplete} + """ + @spec decode_vli(binary()) :: {:ok, non_neg_integer(), binary()} | {:error, :incomplete} + def decode_vli(<<0::2, n::6, rest::binary>>), do: {:ok, n, rest} + def decode_vli(<<1::2, n::14, rest::binary>>), do: {:ok, n, rest} + def decode_vli(<<2::2, n::30, rest::binary>>), do: {:ok, n, rest} + def decode_vli(<<3::2, n::62, rest::binary>>), do: {:ok, n, rest} + def decode_vli(_), do: {:error, :incomplete} + + # --------------------------------------------------------------------------- + # Control stream preface + # --------------------------------------------------------------------------- + + @control_stream_type 0x00 + + # --------------------------------------------------------------------------- + + @doc """ + Returns the binary that MUST be written immediately after opening an HTTP/3 + control stream (RFC 9114 §6.2.1). + + The preface consists of: + + 1. **Stream-type byte** `0x00` — marks this unidirectional QUIC stream as + an HTTP/3 control stream. + 2. **SETTINGS frame** — type `0x04`, 4-byte payload: + - `QPACK_MAX_TABLE_CAPACITY = 0` (`id = 0x01`, `value = 0x00`) + - `QPACK_BLOCKED_STREAMS = 0` (`id = 0x07`, `value = 0x00`) + + Advertising both settings at zero declares static-only QPACK mode, which + requires no dedicated encoder or decoder streams. + + ## Example + + iex> Ankh.Protocol.HTTP3.Frame.control_stream_preface() + <<0x00, 0x04, 0x04, 0x01, 0x00, 0x07, 0x00>> + """ + @spec control_stream_preface() :: binary() + def control_stream_preface do + << + @control_stream_type, + # SETTINGS frame type (VLI 1-byte) + 0x04, + # SETTINGS payload length: 4 bytes (VLI 1-byte) + 0x04, + # QPACK_MAX_TABLE_CAPACITY = 0 (id=0x01, value=0x00) + 0x01, + 0x00, + # QPACK_BLOCKED_STREAMS = 0 (id=0x07, value=0x00) + 0x07, + 0x00 + >> + end +end diff --git a/lib/ankh/protocol/http3/frame/cancel_push.ex b/lib/ankh/protocol/http3/frame/cancel_push.ex new file mode 100644 index 0000000..398f115 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/cancel_push.ex @@ -0,0 +1,33 @@ +defmodule Ankh.Protocol.HTTP3.Frame.CancelPush do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{push_id: non_neg_integer()} + defstruct push_id: 0 + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + alias Ankh.Protocol.HTTP3.Frame + + def decode(%@for{} = payload, <<0::2, n::6, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<1::2, n::14, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<2::2, n::30, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<3::2, n::62, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{push_id: id}), do: {:ok, Frame.encode_vli(id)} + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x3, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/data.ex b/lib/ankh/protocol/http3/frame/data.ex new file mode 100644 index 0000000..43a0631 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/data.ex @@ -0,0 +1,22 @@ +defmodule Ankh.Protocol.HTTP3.Frame.Data do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{data: binary()} + defstruct data: <<>> + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + def decode(%@for{} = payload, binary) when is_binary(binary), + do: {:ok, %{payload | data: binary}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{data: data}), do: {:ok, data} + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x0, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/encodable.ex b/lib/ankh/protocol/http3/frame/encodable.ex new file mode 100644 index 0000000..3a12aca --- /dev/null +++ b/lib/ankh/protocol/http3/frame/encodable.ex @@ -0,0 +1,44 @@ +defprotocol Ankh.Protocol.HTTP3.Frame.Encodable do + @moduledoc """ + Protocol for encoding and decoding HTTP/3 frame payloads to and from their + wire binary representation. + + Every concrete HTTP/3 frame payload module (e.g. + `Ankh.Protocol.HTTP3.Frame.Data.Payload`) implements this protocol. + + Unlike the HTTP/2 equivalent (`Ankh.Protocol.HTTP2.Frame.Encodable`), + HTTP/3 frames carry no per-frame flags byte, so encoding and decoding + require no additional context beyond the payload struct itself and the raw + binary. + + The `@fallback_to_any true` declaration provides a no-op implementation for + `nil` payloads (frames whose payload field is not used). + """ + + @fallback_to_any true + + @typedoc "Any struct that implements `Ankh.Protocol.HTTP3.Frame.Encodable`." + @type t :: any() + + @doc """ + Decode `binary` into the payload struct `data`. + + Returns `{:ok, updated_struct}` on success or `{:error, reason}` on failure. + """ + @spec decode(t(), binary()) :: {:ok, t()} | {:error, any()} + def decode(data, binary) + + @doc """ + Encode the payload struct into an `iodata()` wire representation. + + Returns `{:ok, iodata}` on success or `{:error, reason}` on failure. + """ + @spec encode(t()) :: {:ok, iodata()} | {:error, any()} + def encode(data) +end + +defimpl Ankh.Protocol.HTTP3.Frame.Encodable, for: Any do + # Fallback for nil payload fields (frames with no payload struct). + def decode(_, _), do: {:ok, nil} + def encode(_), do: {:ok, <<>>} +end diff --git a/lib/ankh/protocol/http3/frame/go_away.ex b/lib/ankh/protocol/http3/frame/go_away.ex new file mode 100644 index 0000000..a3064c8 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/go_away.ex @@ -0,0 +1,33 @@ +defmodule Ankh.Protocol.HTTP3.Frame.GoAway do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{stream_id: non_neg_integer()} + defstruct stream_id: 0 + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + alias Ankh.Protocol.HTTP3.Frame + + def decode(%@for{} = payload, <<0::2, n::6, _::binary>>), + do: {:ok, %{payload | stream_id: n}} + + def decode(%@for{} = payload, <<1::2, n::14, _::binary>>), + do: {:ok, %{payload | stream_id: n}} + + def decode(%@for{} = payload, <<2::2, n::30, _::binary>>), + do: {:ok, %{payload | stream_id: n}} + + def decode(%@for{} = payload, <<3::2, n::62, _::binary>>), + do: {:ok, %{payload | stream_id: n}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{stream_id: id}), do: {:ok, Frame.encode_vli(id)} + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x7, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/headers.ex b/lib/ankh/protocol/http3/frame/headers.ex new file mode 100644 index 0000000..252ab6c --- /dev/null +++ b/lib/ankh/protocol/http3/frame/headers.ex @@ -0,0 +1,22 @@ +defmodule Ankh.Protocol.HTTP3.Frame.Headers do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{hbf: binary()} + defstruct hbf: <<>> + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + def decode(%@for{} = payload, binary) when is_binary(binary), + do: {:ok, %{payload | hbf: binary}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{hbf: hbf}), do: {:ok, hbf} + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x1, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/max_push_id.ex b/lib/ankh/protocol/http3/frame/max_push_id.ex new file mode 100644 index 0000000..189d6c7 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/max_push_id.ex @@ -0,0 +1,33 @@ +defmodule Ankh.Protocol.HTTP3.Frame.MaxPushId do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{push_id: non_neg_integer()} + defstruct push_id: 0 + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + alias Ankh.Protocol.HTTP3.Frame + + def decode(%@for{} = payload, <<0::2, n::6, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<1::2, n::14, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<2::2, n::30, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(%@for{} = payload, <<3::2, n::62, _::binary>>), + do: {:ok, %{payload | push_id: n}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{push_id: id}), do: {:ok, Frame.encode_vli(id)} + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0xD, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/push_promise.ex b/lib/ankh/protocol/http3/frame/push_promise.ex new file mode 100644 index 0000000..d659dd5 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/push_promise.ex @@ -0,0 +1,35 @@ +defmodule Ankh.Protocol.HTTP3.Frame.PushPromise do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type t :: %__MODULE__{push_id: non_neg_integer(), hbf: binary()} + defstruct push_id: 0, hbf: <<>> + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + alias Ankh.Protocol.HTTP3.Frame + + def decode(%@for{} = payload, <<0::2, id::6, hbf::binary>>), + do: {:ok, %{payload | push_id: id, hbf: hbf}} + + def decode(%@for{} = payload, <<1::2, id::14, hbf::binary>>), + do: {:ok, %{payload | push_id: id, hbf: hbf}} + + def decode(%@for{} = payload, <<2::2, id::30, hbf::binary>>), + do: {:ok, %{payload | push_id: id, hbf: hbf}} + + def decode(%@for{} = payload, <<3::2, id::62, hbf::binary>>), + do: {:ok, %{payload | push_id: id, hbf: hbf}} + + def decode(_payload, _binary), do: {:error, :decode_error} + + def encode(%@for{push_id: id, hbf: hbf}), + do: {:ok, [Frame.encode_vli(id), hbf]} + + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x5, payload: Payload +end diff --git a/lib/ankh/protocol/http3/frame/settings.ex b/lib/ankh/protocol/http3/frame/settings.ex new file mode 100644 index 0000000..c1def16 --- /dev/null +++ b/lib/ankh/protocol/http3/frame/settings.ex @@ -0,0 +1,65 @@ +defmodule Ankh.Protocol.HTTP3.Frame.Settings do + @moduledoc false + + defmodule Payload do + @moduledoc false + + @type setting :: {non_neg_integer(), non_neg_integer()} + @type t :: %__MODULE__{settings: [setting()]} + defstruct settings: [] + + defimpl Ankh.Protocol.HTTP3.Frame.Encodable do + alias Ankh.Protocol.HTTP3.Frame + + def decode(%@for{} = payload, binary) when is_binary(binary) do + case do_decode(binary, []) do + {:ok, pairs} -> {:ok, %{payload | settings: pairs}} + error -> error + end + end + + def decode(_payload, _binary), do: {:error, :decode_error} + + defp do_decode(<<>>, acc), do: {:ok, Enum.reverse(acc)} + + defp do_decode(<<0::2, id::6, rest::binary>>, acc), + do: do_decode_value(id, rest, acc) + + defp do_decode(<<1::2, id::14, rest::binary>>, acc), + do: do_decode_value(id, rest, acc) + + defp do_decode(<<2::2, id::30, rest::binary>>, acc), + do: do_decode_value(id, rest, acc) + + defp do_decode(<<3::2, id::62, rest::binary>>, acc), + do: do_decode_value(id, rest, acc) + + defp do_decode(_, _acc), do: {:error, :decode_error} + + defp do_decode_value(id, <<0::2, v::6, rest::binary>>, acc), + do: do_decode(rest, [{id, v} | acc]) + + defp do_decode_value(id, <<1::2, v::14, rest::binary>>, acc), + do: do_decode(rest, [{id, v} | acc]) + + defp do_decode_value(id, <<2::2, v::30, rest::binary>>, acc), + do: do_decode(rest, [{id, v} | acc]) + + defp do_decode_value(id, <<3::2, v::62, rest::binary>>, acc), + do: do_decode(rest, [{id, v} | acc]) + + defp do_decode_value(_id, _rest, _acc), do: {:error, :decode_error} + + def encode(%@for{settings: settings}) do + {:ok, + Enum.map(settings, fn {id, value} -> + [Frame.encode_vli(id), Frame.encode_vli(value)] + end)} + end + + def encode(_payload), do: {:error, :encode_error} + end + end + + use Ankh.Protocol.HTTP3.Frame, type: 0x4, payload: Payload +end diff --git a/lib/ankh/protocol/http3/qpack.ex b/lib/ankh/protocol/http3/qpack.ex new file mode 100644 index 0000000..cebf21e --- /dev/null +++ b/lib/ankh/protocol/http3/qpack.ex @@ -0,0 +1,540 @@ +defmodule Ankh.Protocol.HTTP3.QPACK do + @moduledoc """ + QPACK header compression for HTTP/3 (RFC 9204), mirroring the HPAX API + used by the HTTP/2 implementation in Ankh. + + ## API mirror + + This module exposes an API that is intentionally identical in shape to + [HPAX](https://hex.pm/packages/hpax) (the HPACK library used for HTTP/2), + making it straightforward to swap between the two: + + | HPAX function | `Ankh.Protocol.HTTP3.QPACK` function | + |---------------------|--------------------------| + | `HPAX.new/1` | `Ankh.Protocol.HTTP3.QPACK.new/1` | + | `HPAX.encode/3` | `Ankh.Protocol.HTTP3.QPACK.encode/3` | + | `HPAX.decode/2` | `Ankh.Protocol.HTTP3.QPACK.decode/2` | + | `HPAX.resize/2` | `Ankh.Protocol.HTTP3.QPACK.resize/2` | + + ## Static-only mode (Required Insert Count = 0) + + This implementation uses **static-only** QPACK, meaning every header block + is encoded with Required Insert Count = 0. No entries are ever inserted + into the dynamic table, so no encoder stream or decoder stream is required. + This mode is always valid per RFC 9204 and is the correct starting point for + an HTTP/3 implementation before dynamic table negotiation is supported. + + Every encoded header block starts with the two-byte prefix `<<0x00, 0x00>>`: + + - Byte 0: Required Insert Count = 0, 8-bit prefix integer → `0x00` + - Byte 1: Sign bit = 0, Delta Base = 0, 7-bit prefix integer → `0x00` + + After the prefix, each header field is encoded as one of: + + 1. **Indexed Field Line** (RFC 9204 §4.5.2) — when both name and value + match a static table entry. First byte: `0xC0 | index` (6-bit prefix, + T = 1 for static). + 2. **Literal with Static Name Reference** (§4.5.4) — when only the name + matches a static table entry. First byte: `0x50 | name_index` (N = 0) + or `0x70 | name_index` (N = 1), 4-bit prefix, T = 1; followed by a + length-prefixed value string. + 3. **Literal without Name Reference** (§4.5.6) — no static table match. + First byte: `0x20 | name_length` (N = 0) or `0x30 | name_length` + (N = 1), 3-bit prefix, H = 0; followed by the raw name bytes and a + length-prefixed value string. + + Huffman encoding is never applied on the encode path; H = 0 (raw bytes) is + always valid. Huffman-encoded strings are decoded correctly on the receive + path. + + ## Usage + + # Initialise a QPACK state (dynamic table disabled by default) + qpack = Ankh.Protocol.HTTP3.QPACK.new() + + # Encode a list of {name, value} headers + {encoded_block, qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, headers, qpack) + + # Decode a received header block binary + {:ok, headers, qpack} = Ankh.Protocol.HTTP3.QPACK.decode(encoded_block, qpack) + + # Resize the dynamic table capacity (for future dynamic-table support) + qpack = Ankh.Protocol.HTTP3.QPACK.resize(qpack, 4096) + + ## Sensitivity + + The `sensitivity` argument to `encode/3` controls the **N** (never-index) + bit in literal field representations: + + - `:store` — the field may be indexed; N = 0 + - `:no_store` — prefer not to index, but it is permitted; N = 0 + - `:never_store` — must never be added to any index; N = 1 + + Indexed field representations do not carry an N bit and are therefore + unaffected by the sensitivity setting. + """ + + import Bitwise + + alias Ankh.Protocol.HTTP3.QPACK.{Huffman, StaticTable, Table} + + @opaque t :: %__MODULE__{ + table: Table.t() + } + + @typedoc "Public alias for `t/0`, mirroring `HPAX.table/0`." + @type table :: t() + + @typedoc """ + Controls whether a literal header field representation carries the + never-index (N) bit. + + - `:store` — N = 0; the field may be indexed by intermediaries + - `:no_store` — N = 0; the encoder prefers not to index, but permits it + - `:never_store` — N = 1; the field must never be indexed + """ + @type sensitivity :: :store | :no_store | :never_store + + @enforce_keys [:table] + defstruct table: nil + + # Two-byte header block prefix for static-only encoding (RFC 9204 §4.5.1): + # Byte 0 — Required Insert Count = 0 encoded with an 8-bit prefix → 0x00 + # Byte 1 — S = 0, Delta Base = 0 encoded with a 7-bit prefix → 0x00 + @prefix <<0x00, 0x00>> + + # --------------------------------------------------------------------------- + # Public API + # --------------------------------------------------------------------------- + + @doc """ + Create a new QPACK state. + + `max_table_capacity` sets the maximum size in bytes of the dynamic table. + Defaults to `0`, which disables the dynamic table entirely and keeps the + encoder in static-only mode. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.new() + %Ankh.Protocol.HTTP3.QPACK{table: %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 0, size: 0}} + + iex> Ankh.Protocol.HTTP3.QPACK.new(4096) + %Ankh.Protocol.HTTP3.QPACK{table: %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 4096, size: 0}} + + """ + @spec new(non_neg_integer()) :: t() + def new(max_table_capacity \\ 0) + when is_integer(max_table_capacity) and max_table_capacity >= 0 do + %__MODULE__{table: Table.new(max_table_capacity)} + end + + @doc """ + Encode a list of header fields into a QPACK header block. + + Returns `{encoded, new_qpack}` where `encoded` is `iodata()` ready to be + written into a QUIC HEADERS frame payload, and `new_qpack` is the updated + QPACK state. + + The header block always begins with the 2-byte static-only prefix + `<<0x00, 0x00>>`, followed by one field representation per `{name, value}` + pair in wire order. + + ### Encoding strategy (in priority order) + + 1. `{name, value}` matches a static table entry exactly → + **Indexed Field Line** (most compact; no N bit). + 2. `name` alone matches a static table entry → + **Literal with Static Name Reference** (N bit set per `sensitivity`). + 3. No static table match → + **Literal without Name Reference** (N bit set per `sensitivity`). + + Strings are always written with H = 0 (raw bytes, no Huffman compression). + + ## Examples + + iex> qpack = Ankh.Protocol.HTTP3.QPACK.new() + iex> {encoded, _qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, [{":method", "GET"}], qpack) + iex> is_list(encoded) + true + + """ + @spec encode(sensitivity(), [{binary(), binary()}], t()) :: {iodata(), t()} + def encode(sensitivity, headers, %__MODULE__{table: table} = qpack) + when sensitivity in [:store, :no_store, :never_store] and is_list(headers) do + never_index = sensitivity == :never_store + + fields = + Enum.map(headers, fn {name, value} -> + encode_field(name, value, never_index) + end) + + {[@prefix | fields], %__MODULE__{qpack | table: table}} + end + + @doc """ + Decode a QPACK-encoded header block. + + Returns `{:ok, headers, new_qpack}` on success, where `headers` is a list + of `{name, value}` binaries in the order they appear in the block. + + Returns `{:error, reason}` on failure: + + - `:invalid_prefix` — `binary` does not begin with `<<0x00, 0x00>>` + - `:dynamic_table_not_supported` — a field references the dynamic table + - `:invalid_static_index` — a static table index is out of range (0–98) + - `:huffman_error` — Huffman decoding failed on a string field + - `:truncated` — the binary ended unexpectedly mid-field + - `:invalid_data` — any other structural decoding error + + ## Examples + + iex> qpack = Ankh.Protocol.HTTP3.QPACK.new() + iex> {encoded, qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, [{":method", "GET"}], qpack) + iex> {:ok, headers, _qpack} = Ankh.Protocol.HTTP3.QPACK.decode(IO.iodata_to_binary(encoded), qpack) + iex> headers + [{":method", "GET"}] + + """ + @spec decode(binary(), t()) :: {:ok, [{binary(), binary()}], t()} | {:error, atom()} + def decode(<<0x00, 0x00, rest::binary>>, %__MODULE__{table: table} = qpack) do + case decode_fields(rest, table, []) do + {:ok, headers, new_table} -> {:ok, headers, %__MODULE__{qpack | table: new_table}} + {:error, _} = error -> error + end + rescue + _ -> {:error, :invalid_data} + end + + def decode(_binary, _qpack), do: {:error, :invalid_prefix} + + @doc """ + Resize the QPACK dynamic table to a new maximum capacity. + + Evicts the oldest entries first when the new capacity is smaller than the + current occupied size. Setting `max_table_capacity` to `0` disables the + dynamic table entirely. + + ## Examples + + iex> qpack = Ankh.Protocol.HTTP3.QPACK.new(4096) + iex> qpack = Ankh.Protocol.HTTP3.QPACK.resize(qpack, 0) + iex> qpack.table.max_capacity + 0 + + """ + @spec resize(t(), non_neg_integer()) :: t() + def resize(%__MODULE__{table: table} = qpack, max_table_capacity) + when is_integer(max_table_capacity) and max_table_capacity >= 0 do + %__MODULE__{qpack | table: Table.resize(table, max_table_capacity)} + end + + # --------------------------------------------------------------------------- + # Private: field encoding + # --------------------------------------------------------------------------- + + # Encode a single {name, value} pair using the best available representation. + @spec encode_field(binary(), binary(), boolean()) :: iodata() + defp encode_field(name, value, never_index) do + case StaticTable.find_name_value(name, value) do + {:ok, index} -> + # Exact match → Indexed Field Line (static) — RFC 9204 §4.5.2 + encode_indexed_static(index) + + :error -> + case StaticTable.find_name(name) do + {:ok, name_index} -> + # Name-only match → Literal with Static Name Reference — §4.5.4 + encode_literal_name_ref_static(name_index, value, never_index) + + :error -> + # No match → Literal without Name Reference — §4.5.6 + encode_literal_no_name_ref(name, value, never_index) + end + end + end + + # Indexed Field Line (static) — RFC 9204 §4.5.2 + # + # First-byte layout: + # bit 7 = 1 (Indexed Field Line marker) + # bit 6 = T = 1 (static table reference) + # bits 5:0 = Index (6-bit prefix integer; header byte = 0b11000000 = 0xC0) + @spec encode_indexed_static(non_neg_integer()) :: iodata() + defp encode_indexed_static(index) do + encode_integer(6, 0xC0, index) + end + + # Literal Field Line With Name Reference (static) — RFC 9204 §4.5.4 + # + # First-byte layout: + # bit 7 = 0 + # bit 6 = 1 (Literal With Name Reference marker) + # bit 5 = N (never-index flag) + # bit 4 = T = 1 (static table reference) + # bits 3:0 = Name Index (4-bit prefix integer) + # → header byte = 0x50 when N = 0, 0x70 when N = 1 + # Then: value string (H = 0 bit + 7-bit length + raw bytes) + @spec encode_literal_name_ref_static(non_neg_integer(), binary(), boolean()) :: iodata() + defp encode_literal_name_ref_static(name_index, value, never_index) do + header_byte = if never_index, do: 0x70, else: 0x50 + [encode_integer(4, header_byte, name_index), encode_string(value)] + end + + # Literal Field Line Without Name Reference — RFC 9204 §4.5.6 + # + # First-byte layout: + # bit 7 = 0 + # bit 6 = 0 + # bit 5 = 1 (Literal Without Name Reference marker) + # bit 4 = N (never-index flag) + # bit 3 = H = 0 (no Huffman encoding for the name) + # bits 2:0 = NameLen (3-bit prefix integer) + # → header byte = 0x20 when N = 0, 0x30 when N = 1 + # Then: raw name bytes, then value string (H bit + 7-bit length + raw bytes) + @spec encode_literal_no_name_ref(binary(), binary(), boolean()) :: iodata() + defp encode_literal_no_name_ref(name, value, never_index) do + header_byte = if never_index, do: 0x30, else: 0x20 + [encode_integer(3, header_byte, byte_size(name)), name, encode_string(value)] + end + + # Encode a QPACK string with H = 0 (no Huffman): + # H = 0 is stored in bit 7 of the first byte (header_byte = 0x00). + # The byte count follows as a 7-bit prefix integer. + # The raw bytes follow immediately. + @spec encode_string(binary()) :: iodata() + defp encode_string(value) do + [encode_integer(7, 0x00, byte_size(value)), value] + end + + # --------------------------------------------------------------------------- + # Private: integer encoding (RFC 9204 §4.1.1 ≡ RFC 7541 §5.1) + # --------------------------------------------------------------------------- + + # Encode `value` as a prefix integer with `prefix_bits` bits. + # `header_byte` supplies the fixed high-order flag bits that occupy the + # positions above the prefix in the first encoded byte. + # + # When value < 2^prefix_bits - 1, the whole integer fits in the first byte. + # Otherwise the first byte carries the all-ones sentinel and the remainder is + # expressed in one or more 7-bit continuation bytes. + @spec encode_integer(pos_integer(), non_neg_integer(), non_neg_integer()) :: iodata() + defp encode_integer(prefix_bits, header_byte, value) do + max = (1 <<< prefix_bits) - 1 + + if value < max do + [<>] + else + [<> | encode_integer_continuation(value - max)] + end + end + + # Emit multi-byte continuation octets for the portion of an integer that + # overflowed the prefix. + # + # Each continuation byte encodes 7 bits of the remaining value; the high bit + # (bit 7) is set to 1 when another byte follows, and 0 on the final byte. + # + # When the remainder is exactly 0 (i.e. the original value equalled the + # prefix maximum 2^N - 1), we must still emit <<0>> so the decoder correctly + # terminates the continuation sequence. The `n < 128` guard handles n = 0 + # by producing [<<0>>]. + @spec encode_integer_continuation(non_neg_integer()) :: iolist() + defp encode_integer_continuation(n) when n < 128, do: [<>] + + defp encode_integer_continuation(n) do + [<<(n &&& 0x7F) ||| 0x80>> | encode_integer_continuation(n >>> 7)] + end + + # --------------------------------------------------------------------------- + # Private: field decoding (RFC 9204 §4.5) + # --------------------------------------------------------------------------- + + @spec decode_fields(binary(), Table.t(), [{binary(), binary()}]) :: + {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} + + # All field lines consumed — return accumulated headers in wire order. + defp decode_fields(<<>>, table, acc) do + {:ok, Enum.reverse(acc), table} + end + + # Indexed Field Line — bit 7 = 1 (§4.5.2 static / §4.5.3 post-base dynamic) + defp decode_fields(<<1::1, _::bits>> = data, table, acc) do + decode_indexed(data, table, acc) + end + + # Literal Field Line With Name Reference — bits 7:6 = 01 (§4.5.4 / §4.5.5) + defp decode_fields(<<0::1, 1::1, _::bits>> = data, table, acc) do + decode_literal_name_ref(data, table, acc) + end + + # Literal Field Line Without Name Reference — bits 7:5 = 001 (§4.5.6) + defp decode_fields(<<0::1, 0::1, 1::1, _::bits>> = data, table, acc) do + decode_literal_no_name_ref(data, table, acc) + end + + # Post-Base Indexed Field Line — bits 7:4 = 0001 (§4.5.3, dynamic table only) + defp decode_fields(<<0::1, 0::1, 0::1, 1::1, _::bits>>, _table, _acc) do + {:error, :dynamic_table_not_supported} + end + + # Post-Base Literal Field Line With Name Reference — bits 7:4 = 0000 (§4.5.5) + defp decode_fields(<<0::1, 0::1, 0::1, 0::1, _::bits>>, _table, _acc) do + {:error, :dynamic_table_not_supported} + end + + # Indexed Field Line — RFC 9204 §4.5.2 (static) and §4.5.3 (post-base dynamic) + # + # First-byte layout: 1 T Index(6+) + # bit 6 = T: 1 = static table, 0 = post-base dynamic table + # bits 5:0 = Index as a 6-bit prefix integer + @spec decode_indexed(binary(), Table.t(), [{binary(), binary()}]) :: + {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} + defp decode_indexed(<> = data, table, acc) do + static? = (byte &&& 0x40) != 0 + {index, rest} = decode_integer(6, data) + + if static? do + case StaticTable.lookup(index) do + {:ok, header} -> decode_fields(rest, table, [header | acc]) + :error -> {:error, :invalid_static_index} + end + else + {:error, :dynamic_table_not_supported} + end + end + + # Literal Field Line With Name Reference — RFC 9204 §4.5.4 (static) + # + # First-byte layout: 0 1 N T NameIndex(4+) + # bit 5 = N: never-index flag (informational on decode) + # bit 4 = T: 1 = static table, 0 = dynamic table + # bits 3:0 = Name Index as a 4-bit prefix integer + # Then: value string (H bit + 7-bit length + bytes) + @spec decode_literal_name_ref(binary(), Table.t(), [{binary(), binary()}]) :: + {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} + defp decode_literal_name_ref(<> = data, table, acc) do + static? = (byte &&& 0x10) != 0 + {name_index, rest} = decode_integer(4, data) + + if static? do + with {:ok, {name, _cached_value}} <- StaticTable.lookup(name_index), + {:ok, value, rest2} <- decode_string(rest) do + decode_fields(rest2, table, [{name, value} | acc]) + else + :error -> {:error, :invalid_static_index} + {:error, _} = err -> err + end + else + {:error, :dynamic_table_not_supported} + end + end + + # Literal Field Line Without Name Reference — RFC 9204 §4.5.6 + # + # First-byte layout: 0 0 1 N H NameLen(3+) + # bit 4 = N: never-index flag (informational on decode) + # bit 3 = H: Huffman flag for the name bytes + # bits 2:0 = Name length as a 3-bit prefix integer + # + # The H bit for the name sits at bit 3, which is above the 3-bit prefix + # mask (0x07), so it does not affect the length integer decoded by + # `decode_integer/2` and must be extracted separately. + # + # Then: raw (or Huffman-decoded) name bytes + # Then: value string (H bit + 7-bit length + bytes) + @spec decode_literal_no_name_ref(binary(), Table.t(), [{binary(), binary()}]) :: + {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} + defp decode_literal_no_name_ref(<> = data, table, acc) do + huffman_name? = (byte &&& 0x08) != 0 + {name_length, rest} = decode_integer(3, data) + + case read_string_data(rest, name_length, huffman_name?) do + {:ok, name, rest2} -> + case decode_string(rest2) do + {:ok, value, rest3} -> decode_fields(rest3, table, [{name, value} | acc]) + {:error, _} = err -> err + end + + {:error, _} = err -> + err + end + end + + # Decode a standard QPACK string: + # bit 7 of the first byte = H (Huffman flag) + # bits 6:0 of the first byte = start of a 7-bit prefix integer for length + # then `length` bytes of string data (raw or Huffman-encoded) + @spec decode_string(binary()) :: {:ok, binary(), binary()} | {:error, atom()} + defp decode_string(<> = data) do + huffman? = (byte &&& 0x80) != 0 + {length, rest} = decode_integer(7, data) + read_string_data(rest, length, huffman?) + end + + defp decode_string(<<>>), do: {:error, :truncated} + + # Read exactly `length` bytes from `data`, applying Huffman decoding when + # `huffman?` is true. Returns `{:error, :truncated}` when fewer than + # `length` bytes are available. + @spec read_string_data(binary(), non_neg_integer(), boolean()) :: + {:ok, binary(), binary()} | {:error, atom()} + defp read_string_data(data, length, huffman?) do + if byte_size(data) < length do + {:error, :truncated} + else + <> = data + apply_huffman(raw, rest, huffman?) + end + end + + @spec apply_huffman(binary(), binary(), boolean()) :: + {:ok, binary(), binary()} | {:error, atom()} + defp apply_huffman(raw, rest, false), do: {:ok, raw, rest} + + defp apply_huffman(raw, rest, true) do + case Huffman.decode(raw) do + {:ok, decoded} -> {:ok, decoded, rest} + {:error, reason} -> {:error, reason} + end + end + + # --------------------------------------------------------------------------- + # Private: integer decoding (RFC 9204 §4.1.1 ≡ RFC 7541 §5.1) + # --------------------------------------------------------------------------- + + # Decode a prefix integer with `prefix_bits` bits from `data`. + # + # The upper bits of the first byte (those above the prefix) are format flags + # and are masked away before inspecting the integer value. When the masked + # value is less than 2^prefix_bits - 1, the integer fits in that single byte. + # When it equals the maximum, additional continuation bytes are consumed. + @spec decode_integer(pos_integer(), binary()) :: {non_neg_integer(), binary()} + defp decode_integer(prefix_bits, <>) do + mask = (1 <<< prefix_bits) - 1 + value = first_byte &&& mask + + if value < mask do + {value, rest} + else + decode_integer_continuation(rest, value, 0) + end + end + + # Accumulate 7-bit continuation bytes into the integer value. + # + # Each byte contributes 7 bits (bits 6:0) shifted left by `shift` positions + # relative to the current accumulator. Bit 7 of each byte indicates whether + # another continuation byte follows (1 = more, 0 = last byte). + @spec decode_integer_continuation(binary(), non_neg_integer(), non_neg_integer()) :: + {non_neg_integer(), binary()} + defp decode_integer_continuation(<>, acc, shift) do + new_acc = acc + ((byte &&& 0x7F) <<< shift) + + if (byte &&& 0x80) != 0 do + decode_integer_continuation(rest, new_acc, shift + 7) + else + {new_acc, rest} + end + end +end diff --git a/lib/ankh/protocol/http3/qpack/huffman.ex b/lib/ankh/protocol/http3/qpack/huffman.ex new file mode 100644 index 0000000..2d2d73a --- /dev/null +++ b/lib/ankh/protocol/http3/qpack/huffman.ex @@ -0,0 +1,634 @@ +defmodule Ankh.Protocol.HTTP3.QPACK.Huffman do + @moduledoc """ + Huffman encoding and decoding for QPACK and HPACK, implementing the static + Huffman code defined in RFC 7541 Appendix B. + + The symbol alphabet has 257 entries: byte values 0–255 plus the EOS + (end-of-string) symbol 256. Code lengths range from 5 bits (common ASCII + printable characters) to 30 bits (EOS and rare control characters). + + ## Encoding + + Each input byte is looked up in the compile-time `@encode_table` to obtain + its `{code_integer, bit_length}` pair. Codes are packed left-to-right into + an accumulator integer; the final incomplete byte is padded on the right + with `1`-bits — the most-significant bits of the EOS code — as required by + RFC 7541 § 5.2. + + ## Decoding + + A prefix trie is built at compile time (`@decode_trie`) from the Huffman + table. The trie is a flat `%{node_id => {child_for_0, child_for_1}}` map + where each child is `{:leaf, symbol}`, `{:node, id}`, or `nil`. Input bits + are consumed one at a time; upon reaching a leaf the decoded byte is emitted + and traversal restarts from the root (node 0). + + At end-of-input, 1–7 trailing `1`-bits are accepted as valid EOS padding + (RFC 7541 § 5.2). Any other trailing trie state, EOS appearing mid-stream, + or an unrecognised bit sequence is rejected with `{:error, :huffman_error}`. + + ### Note on symbol 249 + + A common transcription of the RFC 7541 table writes symbol 249 as + `{0xfffffe, 28}` (6 hex digits). The RFC itself specifies `ffffffe [28]` + (7 hex digits, code integer `0xffffffe`). The shorter form would create a + prefix collision with symbol 49 (`'1'`, 5-bit code `0x1 = 00001`). This + module uses the RFC-correct value `0xffffffe`. + """ + + import Bitwise + + # --------------------------------------------------------------------------- + # RFC 7541 Appendix B Huffman code table + # {symbol, code_integer, code_length_in_bits} + # All 257 entries — symbols 0–255 (byte values) plus 256 (EOS). + # --------------------------------------------------------------------------- + + @table [ + # Control characters 0–31 + {0, 0x1FF8, 13}, + {1, 0x7FFFD8, 23}, + {2, 0xFFFFFE2, 28}, + {3, 0xFFFFFE3, 28}, + {4, 0xFFFFFE4, 28}, + {5, 0xFFFFFE5, 28}, + {6, 0xFFFFFE6, 28}, + {7, 0xFFFFFE7, 28}, + {8, 0xFFFFFE8, 28}, + {9, 0xFFFFEA, 24}, + {10, 0x3FFFFFFC, 30}, + {11, 0xFFFFFE9, 28}, + {12, 0xFFFFFEA, 28}, + {13, 0x3FFFFFFD, 30}, + {14, 0xFFFFFEB, 28}, + {15, 0xFFFFFEC, 28}, + {16, 0xFFFFFED, 28}, + {17, 0xFFFFFEE, 28}, + {18, 0xFFFFFEF, 28}, + {19, 0xFFFFFF0, 28}, + {20, 0xFFFFFF1, 28}, + {21, 0xFFFFFF2, 28}, + {22, 0x3FFFFFFE, 30}, + {23, 0xFFFFFF3, 28}, + {24, 0xFFFFFF4, 28}, + {25, 0xFFFFFF5, 28}, + {26, 0xFFFFFF6, 28}, + {27, 0xFFFFFF7, 28}, + {28, 0xFFFFFF8, 28}, + {29, 0xFFFFFF9, 28}, + {30, 0xFFFFFFA, 28}, + {31, 0xFFFFFFB, 28}, + # Printable ASCII 32–126 + # ' ' + {32, 0x14, 6}, + # '!' + {33, 0x3F8, 10}, + # '"' + {34, 0x3F9, 10}, + # '#' + {35, 0xFFA, 12}, + # '$' + {36, 0x1FF9, 13}, + # '%' + {37, 0x15, 6}, + # '&' + {38, 0xF8, 8}, + # '\'' + {39, 0x7FA, 11}, + # '(' + {40, 0x3FA, 10}, + # ')' + {41, 0x3FB, 10}, + # '*' + {42, 0xF9, 8}, + # '+' + {43, 0x7FB, 11}, + # ',' + {44, 0xFA, 8}, + # '-' + {45, 0x16, 6}, + # '.' + {46, 0x17, 6}, + # '/' + {47, 0x18, 6}, + # '0' + {48, 0x0, 5}, + # '1' + {49, 0x1, 5}, + # '2' + {50, 0x2, 5}, + # '3' + {51, 0x19, 6}, + # '4' + {52, 0x1A, 6}, + # '5' + {53, 0x1B, 6}, + # '6' + {54, 0x1C, 6}, + # '7' + {55, 0x1D, 6}, + # '8' + {56, 0x1E, 6}, + # '9' + {57, 0x1F, 6}, + # ':' + {58, 0x5C, 7}, + # ';' + {59, 0xFB, 8}, + # '<' + {60, 0x7FFC, 15}, + # '=' + {61, 0x20, 6}, + # '>' + {62, 0xFFB, 12}, + # '?' + {63, 0x3FC, 10}, + # '@' + {64, 0x1FFA, 13}, + # 'A' + {65, 0x21, 6}, + # 'B' + {66, 0x5D, 7}, + # 'C' + {67, 0x5E, 7}, + # 'D' + {68, 0x5F, 7}, + # 'E' + {69, 0x60, 7}, + # 'F' + {70, 0x61, 7}, + # 'G' + {71, 0x62, 7}, + # 'H' + {72, 0x63, 7}, + # 'I' + {73, 0x64, 7}, + # 'J' + {74, 0x65, 7}, + # 'K' + {75, 0x66, 7}, + # 'L' + {76, 0x67, 7}, + # 'M' + {77, 0x68, 7}, + # 'N' + {78, 0x69, 7}, + # 'O' + {79, 0x6A, 7}, + # 'P' + {80, 0x6B, 7}, + # 'Q' + {81, 0x6C, 7}, + # 'R' + {82, 0x6D, 7}, + # 'S' + {83, 0x6E, 7}, + # 'T' + {84, 0x6F, 7}, + # 'U' + {85, 0x70, 7}, + # 'V' + {86, 0x71, 7}, + # 'W' + {87, 0x72, 7}, + # 'X' + {88, 0xFC, 8}, + # 'Y' + {89, 0x73, 7}, + # 'Z' + {90, 0xFD, 8}, + # '[' + {91, 0x1FFB, 13}, + # '\' + {92, 0x7FFF0, 19}, + # ']' + {93, 0x1FFC, 13}, + # '^' + {94, 0x3FFC, 14}, + # '_' + {95, 0x22, 6}, + # '`' + {96, 0x7FFD, 15}, + # 'a' + {97, 0x3, 5}, + # 'b' + {98, 0x23, 6}, + # 'c' + {99, 0x4, 5}, + # 'd' + {100, 0x24, 6}, + # 'e' + {101, 0x5, 5}, + # 'f' + {102, 0x25, 6}, + # 'g' + {103, 0x26, 6}, + # 'h' + {104, 0x27, 6}, + # 'i' + {105, 0x6, 5}, + # 'j' + {106, 0x74, 7}, + # 'k' + {107, 0x75, 7}, + # 'l' + {108, 0x28, 6}, + # 'm' + {109, 0x29, 6}, + # 'n' + {110, 0x2A, 6}, + # 'o' + {111, 0x7, 5}, + # 'p' + {112, 0x2B, 6}, + # 'q' + {113, 0x76, 7}, + # 'r' + {114, 0x2C, 6}, + # 's' + {115, 0x8, 5}, + # 't' + {116, 0x9, 5}, + # 'u' + {117, 0x2D, 6}, + # 'v' + {118, 0x77, 7}, + # 'w' + {119, 0x78, 7}, + # 'x' + {120, 0x79, 7}, + # 'y' + {121, 0x7A, 7}, + # 'z' + {122, 0x7B, 7}, + # '{' + {123, 0x7FFE, 15}, + # '|' + {124, 0x7FC, 11}, + # '}' + {125, 0x3FFD, 14}, + # '~' + {126, 0x1FFD, 13}, + # Extended / non-printable 127–255 + {127, 0xFFFFFFC, 28}, + {128, 0xFFFE6, 20}, + {129, 0x3FFFD2, 22}, + {130, 0xFFFE7, 20}, + {131, 0xFFFE8, 20}, + {132, 0x3FFFD3, 22}, + {133, 0x3FFFD4, 22}, + {134, 0x3FFFD5, 22}, + {135, 0x7FFFD9, 23}, + {136, 0x3FFFD6, 22}, + {137, 0x7FFFDA, 23}, + {138, 0x7FFFDB, 23}, + {139, 0x7FFFDC, 23}, + {140, 0x7FFFDD, 23}, + {141, 0x7FFFDE, 23}, + {142, 0xFFFFEB, 24}, + {143, 0x7FFFDF, 23}, + {144, 0xFFFFEC, 24}, + {145, 0xFFFFED, 24}, + {146, 0x3FFFD7, 22}, + {147, 0x7FFFE0, 23}, + {148, 0xFFFFEE, 24}, + {149, 0x7FFFE1, 23}, + {150, 0x7FFFE2, 23}, + {151, 0x7FFFE3, 23}, + {152, 0x7FFFE4, 23}, + {153, 0x1FFFDC, 21}, + {154, 0x3FFFD8, 22}, + {155, 0x7FFFE5, 23}, + {156, 0x3FFFD9, 22}, + {157, 0x7FFFE6, 23}, + {158, 0x7FFFE7, 23}, + {159, 0xFFFFEF, 24}, + {160, 0x3FFFDA, 22}, + {161, 0x1FFFDD, 21}, + {162, 0xFFFE9, 20}, + {163, 0x3FFFDB, 22}, + {164, 0x3FFFDC, 22}, + {165, 0x7FFFE8, 23}, + {166, 0x7FFFE9, 23}, + {167, 0x1FFFDE, 21}, + {168, 0x7FFFEA, 23}, + {169, 0x3FFFDD, 22}, + {170, 0x3FFFDE, 22}, + {171, 0xFFFFF0, 24}, + {172, 0x1FFFDF, 21}, + {173, 0x3FFFDF, 22}, + {174, 0x7FFFEB, 23}, + {175, 0x7FFFEC, 23}, + {176, 0x1FFFE0, 21}, + {177, 0x1FFFE1, 21}, + {178, 0x3FFFE0, 22}, + {179, 0x1FFFE2, 21}, + {180, 0x7FFFED, 23}, + {181, 0x3FFFE1, 22}, + {182, 0x7FFFEE, 23}, + {183, 0x7FFFEF, 23}, + {184, 0xFFFEA, 20}, + {185, 0x3FFFE2, 22}, + {186, 0x3FFFE3, 22}, + {187, 0x3FFFE4, 22}, + {188, 0x7FFFF0, 23}, + {189, 0x3FFFE5, 22}, + {190, 0x3FFFE6, 22}, + {191, 0x7FFFF1, 23}, + {192, 0x3FFFFE0, 26}, + {193, 0x3FFFFE1, 26}, + {194, 0xFFFEB, 20}, + {195, 0x7FFF1, 19}, + {196, 0x3FFFE7, 22}, + {197, 0x7FFFF2, 23}, + {198, 0x3FFFE8, 22}, + {199, 0x1FFFFEC, 25}, + {200, 0x3FFFFE2, 26}, + {201, 0x3FFFFE3, 26}, + {202, 0x3FFFFE4, 26}, + {203, 0x7FFFFDE, 27}, + {204, 0x7FFFFDF, 27}, + {205, 0x3FFFFE5, 26}, + {206, 0xFFFFF1, 24}, + {207, 0x1FFFFED, 25}, + {208, 0x7FFF2, 19}, + {209, 0x1FFFE3, 21}, + {210, 0x3FFFFE6, 26}, + {211, 0x7FFFFE0, 27}, + {212, 0x7FFFFE1, 27}, + {213, 0x3FFFFE7, 26}, + {214, 0x7FFFFE2, 27}, + {215, 0xFFFFF2, 24}, + {216, 0x1FFFE4, 21}, + {217, 0x1FFFE5, 21}, + {218, 0x3FFFFE8, 26}, + {219, 0x3FFFFE9, 26}, + {220, 0xFFFFFFD, 28}, + {221, 0x7FFFFE3, 27}, + {222, 0x7FFFFE4, 27}, + {223, 0x7FFFFE5, 27}, + {224, 0xFFFEC, 20}, + {225, 0xFFFFF3, 24}, + {226, 0xFFFED, 20}, + {227, 0x1FFFE6, 21}, + {228, 0x3FFFE9, 22}, + {229, 0x1FFFE7, 21}, + {230, 0x1FFFE8, 21}, + {231, 0x7FFFF3, 23}, + {232, 0x3FFFEA, 22}, + {233, 0x3FFFEB, 22}, + {234, 0x1FFFFEE, 25}, + {235, 0x1FFFFEF, 25}, + {236, 0xFFFFF4, 24}, + {237, 0xFFFFF5, 24}, + {238, 0x3FFFFEA, 26}, + {239, 0x7FFFF4, 23}, + {240, 0x3FFFFEB, 26}, + {241, 0x7FFFFE6, 27}, + {242, 0x3FFFFEC, 26}, + {243, 0x3FFFFED, 26}, + {244, 0x7FFFFE7, 27}, + {245, 0x7FFFFE8, 27}, + {246, 0x7FFFFE9, 27}, + {247, 0x7FFFFEA, 27}, + {248, 0x7FFFFEB, 27}, + # RFC 7541: ffffffe [28] — 27 ones followed by 0. + # A common transcription error writes this as 0xfffffe (24-bit value) + # which collides with symbol 49 ('1'). The correct value is 0xffffffe. + {249, 0xFFFFFFE, 28}, + {250, 0x7FFFFEC, 27}, + {251, 0x7FFFFED, 27}, + {252, 0x7FFFFEE, 27}, + {253, 0x7FFFFEF, 27}, + {254, 0x7FFFFF0, 27}, + {255, 0x3FFFFEE, 26}, + # EOS — end-of-string; used only as padding prefix, never encoded directly. + {256, 0x3FFFFFFF, 30} + ] + + # --------------------------------------------------------------------------- + # Compile-time encode table: byte_value => {code_integer, code_length_in_bits} + # EOS (256) is excluded — it is never encoded directly. + # --------------------------------------------------------------------------- + + @encode_table (for {sym, code, len} <- @table, sym < 256, into: %{} do + {sym, {code, len}} + end) + + # --------------------------------------------------------------------------- + # Compile-time decode trie. + # + # Representation: %{node_id => {child_for_bit_0, child_for_bit_1}} + # where each child is one of: + # {:leaf, symbol} — a complete Huffman code ends here + # {:node, node_id} — continue traversal + # nil — no valid code takes this branch + # + # Root is node 0; new node IDs are assigned sequentially. + # + # For each table entry we extract the code bits MSB-first, walk the trie + # (creating internal nodes on demand), and place a {:leaf, symbol} at the + # terminal position. + # --------------------------------------------------------------------------- + + @decode_trie Enum.reduce( + @table, + {%{0 => {nil, nil}}, 1}, + fn {symbol, code, len}, {trie, next_id} -> + # Extract bits MSB-first. + bits = for i <- (len - 1)..0//-1, do: band(bsr(code, i), 1) + + # Everything except the last bit forms the path through internal nodes. + {path_bits, [last_bit]} = Enum.split(bits, len - 1) + + # Walk the internal-node path, creating nodes as needed. + # We use `tr`/`nxt` inside the inner reduce to avoid rebinding the + # outer `trie`/`next_id` before we are done with them. + {trie, next_id, leaf_parent} = + Enum.reduce(path_bits, {trie, next_id, 0}, fn bit, {tr, nxt, cur} -> + {c0, c1} = Map.get(tr, cur, {nil, nil}) + existing_child = if bit == 0, do: c0, else: c1 + + {child_id, tr, nxt} = + case existing_child do + {:node, id} -> + # Node already exists; just descend. + {id, tr, nxt} + + nil -> + # Create a fresh internal node and update the parent pointer. + new_id = nxt + tr = Map.put(tr, new_id, {nil, nil}) + + updated_parent = + if bit == 0, + do: {{:node, new_id}, c1}, + else: {c0, {:node, new_id}} + + {new_id, Map.put(tr, cur, updated_parent), nxt + 1} + end + + {tr, nxt, child_id} + end) + + # Place the leaf at the last-bit position of the leaf's parent node. + {c0, c1} = Map.get(trie, leaf_parent, {nil, nil}) + + leaf_entry = + if last_bit == 0, + do: {{:leaf, symbol}, c1}, + else: {c0, {:leaf, symbol}} + + {Map.put(trie, leaf_parent, leaf_entry), next_id} + end + ) + |> elem(0) + + # --------------------------------------------------------------------------- + # Compile-time valid padding terminal nodes. + # + # After the last decoded symbol, the remaining 1–7 bits must be all `1`s + # (the EOS prefix, RFC 7541 § 5.2). In the trie those bits lead exclusively + # rightward (bit = 1) — the same path as EOS (30 ones). We precompute the + # node IDs at depths 1–7 on that all-ones path so that end-of-stream + # validation is a single O(1) MapSet lookup. + # --------------------------------------------------------------------------- + + @padding_valid_nodes Enum.reduce_while( + 1..7, + {0, MapSet.new()}, + fn _, {cur_id, valid_set} -> + case @decode_trie[cur_id] do + {_c0, {:node, next_id}} -> + {:cont, {next_id, MapSet.put(valid_set, next_id)}} + + _ -> + # EOS path shorter than 7 — should not happen with a correct table. + {:halt, {cur_id, valid_set}} + end + end + ) + |> elem(1) + + # --------------------------------------------------------------------------- + # Public API + # --------------------------------------------------------------------------- + + @doc """ + Decode a Huffman-encoded binary according to RFC 7541 Appendix B. + + Returns `{:ok, decoded_binary}` on success. + + Returns `{:error, :huffman_error}` when: + + * an unrecognised bit sequence is encountered; + * the EOS symbol (256) appears mid-stream; + * trailing padding is longer than 7 bits; or + * trailing padding bits are not all `1`s. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.Huffman.decode(<<0x1f>>) + {:ok, "a"} + + iex> Ankh.Protocol.HTTP3.QPACK.Huffman.decode(<<>>) + {:ok, ""} + + """ + @spec decode(binary()) :: {:ok, binary()} | {:error, :huffman_error} + def decode(data) when is_binary(data) do + decode_bits(data, 0, []) + end + + @doc """ + Encode a binary using the RFC 7541 Huffman code. + + Each byte in `data` is replaced by its variable-length Huffman code. The + resulting bit stream is padded on the right to the next byte boundary with + `1`-bits (the EOS prefix), as required by RFC 7541 § 5.2. + + Encoding never fails; every byte value 0–255 has a table entry. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.Huffman.encode("a") + <<0x1f>> + + iex> Ankh.Protocol.HTTP3.QPACK.Huffman.encode("") + <<>> + + """ + @spec encode(binary()) :: binary() + def encode(data) when is_binary(data) do + # Accumulate all codes into a single arbitrary-precision integer together + # with the running bit count. + {bits, length} = + :binary.bin_to_list(data) + |> Enum.reduce({0, 0}, fn byte, {acc_bits, acc_len} -> + {code, len} = Map.fetch!(@encode_table, byte) + {bor(bsl(acc_bits, len), code), acc_len + len} + end) + + # Pad the last byte to a byte boundary with 1-bits. + # rem(8 - rem(length, 8), 8) yields 0 when length is already aligned. + padding = rem(8 - rem(length, 8), 8) + total_bits = length + padding + # bsl(1, padding) - 1 produces `padding` ones; harmless when padding == 0. + padded_bits = bor(bsl(bits, padding), bsl(1, padding) - 1) + + case total_bits do + 0 -> <<>> + n -> <> + end + end + + # --------------------------------------------------------------------------- + # Private: recursive bit-by-bit trie traversal + # --------------------------------------------------------------------------- + + # End of input sitting at the trie root — all symbols decoded, no padding. + defp decode_bits(<<>>, 0, acc) do + {:ok, :erlang.list_to_binary(Enum.reverse(acc))} + end + + # End of input at an intermediate node. Valid iff those bits were 1–7 all-one + # padding bits, i.e. the node is on the all-ones (EOS prefix) path at depth + # 1–7. + defp decode_bits(<<>>, node_id, acc) do + if MapSet.member?(@padding_valid_nodes, node_id) do + {:ok, :erlang.list_to_binary(Enum.reverse(acc))} + else + {:error, :huffman_error} + end + end + + # Consume one bit and advance through the trie. + defp decode_bits(<>, node_id, acc) do + case @decode_trie[node_id] do + nil -> + # node_id not in trie — should not happen with a valid trie. + {:error, :huffman_error} + + {child0, child1} -> + child = if bit == 0, do: child0, else: child1 + + case child do + # EOS appearing mid-stream is always an error (RFC 7541 § 5.2). + {:leaf, 256} -> + {:error, :huffman_error} + + # Decoded a complete symbol; emit it and restart from the root. + {:leaf, sym} -> + decode_bits(rest, 0, [sym | acc]) + + # Partial code; continue deeper in the trie. + {:node, next_id} -> + decode_bits(rest, next_id, acc) + + # No Huffman code starts with this bit sequence. + nil -> + {:error, :huffman_error} + end + end + end +end diff --git a/lib/ankh/protocol/http3/qpack/static_table.ex b/lib/ankh/protocol/http3/qpack/static_table.ex new file mode 100644 index 0000000..7d0d894 --- /dev/null +++ b/lib/ankh/protocol/http3/qpack/static_table.ex @@ -0,0 +1,227 @@ +defmodule Ankh.Protocol.HTTP3.QPACK.StaticTable do + @moduledoc """ + QPACK static table as defined in RFC 9204, Appendix A. + + The static table contains 99 pre-defined header field entries (indices 0–98) + that both encoder and decoder share without any negotiation. Using static table + references in QPACK-encoded header blocks saves bytes on the wire by replacing + common header name/value pairs with compact integer indices. + + Three compile-time lookup structures are derived from the raw table: + + * `@by_index` — look up a `{name, value}` pair by its integer index. + * `@by_name_value` — look up the index for an exact `{name, value}` match. + * `@by_name` — look up the lowest index that carries a given header name, + useful when only the name (not the value) can be matched + statically. + + All three maps are built at compile time, so every public function in this + module resolves to a single map lookup at runtime. + """ + + # --------------------------------------------------------------------------- + # Raw table — {index, name, value}, 0-based, ordered by index. + # Source: RFC 9204, Appendix A + # --------------------------------------------------------------------------- + + @table [ + {0, ":authority", ""}, + {1, ":path", "/"}, + {2, "age", "0"}, + {3, "content-disposition", ""}, + {4, "content-length", "0"}, + {5, "cookie", ""}, + {6, "date", ""}, + {7, "etag", ""}, + {8, "if-modified-since", ""}, + {9, "if-none-match", ""}, + {10, "last-modified", ""}, + {11, "link", ""}, + {12, "location", ""}, + {13, "referer", ""}, + {14, "set-cookie", ""}, + {15, ":method", "CONNECT"}, + {16, ":method", "DELETE"}, + {17, ":method", "GET"}, + {18, ":method", "HEAD"}, + {19, ":method", "OPTIONS"}, + {20, ":method", "POST"}, + {21, ":method", "PUT"}, + {22, ":scheme", "http"}, + {23, ":scheme", "https"}, + {24, ":status", "103"}, + {25, ":status", "200"}, + {26, ":status", "304"}, + {27, ":status", "404"}, + {28, ":status", "503"}, + {29, "accept", "*/*"}, + {30, "accept", "application/dns-message"}, + {31, "accept-encoding", "gzip, deflate, br"}, + {32, "accept-ranges", "bytes"}, + {33, "access-control-allow-headers", "cache-control"}, + {34, "access-control-allow-headers", "content-type"}, + {35, "access-control-allow-origin", "*"}, + {36, "cache-control", "max-age=0"}, + {37, "cache-control", "max-age=2592000"}, + {38, "cache-control", "max-age=604800"}, + {39, "cache-control", "no-cache"}, + {40, "cache-control", "no-store"}, + {41, "cache-control", "public, max-age=31536000"}, + {42, "content-encoding", "br"}, + {43, "content-encoding", "gzip"}, + {44, "content-type", "application/dns-message"}, + {45, "content-type", "application/javascript"}, + {46, "content-type", "application/json"}, + {47, "content-type", "application/x-www-form-urlencoded"}, + {48, "content-type", "image/gif"}, + {49, "content-type", "image/jpeg"}, + {50, "content-type", "image/png"}, + {51, "content-type", "text/css"}, + {52, "content-type", "text/html; charset=utf-8"}, + {53, "content-type", "text/plain"}, + {54, "content-type", "text/plain;charset=utf-8"}, + {55, "range", "bytes=0-"}, + {56, "strict-transport-security", "max-age=31536000"}, + {57, "strict-transport-security", "max-age=31536000; includesubdomains"}, + {58, "strict-transport-security", "max-age=31536000; includesubdomains; preload"}, + {59, "vary", "accept-encoding"}, + {60, "vary", "origin"}, + {61, "x-content-type-options", "nosniff"}, + {62, "x-xss-protection", "1; mode=block"}, + {63, ":status", "100"}, + {64, ":status", "204"}, + {65, ":status", "206"}, + {66, ":status", "302"}, + {67, ":status", "400"}, + {68, ":status", "403"}, + {69, ":status", "421"}, + {70, ":status", "425"}, + {71, ":status", "500"}, + {72, "accept-language", ""}, + {73, "access-control-allow-credentials", "FALSE"}, + {74, "access-control-allow-credentials", "TRUE"}, + {75, "access-control-allow-headers", "*"}, + {76, "access-control-allow-methods", "get"}, + {77, "access-control-allow-methods", "get, post, options"}, + {78, "access-control-allow-methods", "options"}, + {79, "access-control-expose-headers", "content-length"}, + {80, "access-control-request-headers", "content-type"}, + {81, "access-control-request-method", "get"}, + {82, "access-control-request-method", "post"}, + {83, "alt-svc", "clear"}, + {84, "authorization", ""}, + {85, "content-security-policy", "script-src 'none'; object-src 'none'; base-uri 'none'"}, + {86, "early-data", "1"}, + {87, "expect-ct", ""}, + {88, "forwarded", ""}, + {89, "if-range", ""}, + {90, "origin", ""}, + {91, "purpose", "prefetch"}, + {92, "server", ""}, + {93, "timing-allow-origin", "*"}, + {94, "upgrade-insecure-requests", "1"}, + {95, "user-agent", ""}, + {96, "x-forwarded-for", ""}, + {97, "x-frame-options", "deny"}, + {98, "x-frame-options", "sameorigin"} + ] + + # --------------------------------------------------------------------------- + # Derived compile-time lookup maps + # --------------------------------------------------------------------------- + + # %{index => {name, value}} + @by_index Map.new(@table, fn {index, name, value} -> {index, {name, value}} end) + + # %{{name, value} => index} + # Iterating in ascending index order and using Map.put_new/3 ensures that + # when multiple entries share the same {name, value} pair, the lowest index + # (i.e. the first occurrence) is retained. + @by_name_value Enum.reduce(@table, %{}, fn {index, name, value}, acc -> + Map.put_new(acc, {name, value}, index) + end) + + # %{name => index} + # Same strategy: first (lowest-index) occurrence wins per name. + @by_name Enum.reduce(@table, %{}, fn {index, name, _value}, acc -> + Map.put_new(acc, name, index) + end) + + # --------------------------------------------------------------------------- + # Public API + # --------------------------------------------------------------------------- + + @doc """ + Look up a static table entry by its zero-based index. + + Returns `{:ok, {name, value}}` when the index exists in the static table, + or `:error` when it is out of range. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(0) + {:ok, {":authority", ""}} + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(17) + {:ok, {":method", "GET"}} + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(99) + :error + + """ + @spec lookup(non_neg_integer()) :: {:ok, {binary(), binary()}} | :error + def lookup(index) when is_integer(index) and index >= 0 do + case Map.fetch(@by_index, index) do + {:ok, _} = ok -> ok + :error -> :error + end + end + + @doc """ + Find the static table index for an exact header name + value match. + + When the same `{name, value}` pair appears at multiple indices (which does + not happen in the current RFC 9204 static table, but is guarded against), + the lowest index is returned. + + Returns `{:ok, index}` on a match, or `:error` when no entry matches. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name_value(":method", "GET") + {:ok, 17} + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name_value(":method", "PATCH") + :error + + """ + @spec find_name_value(binary(), binary()) :: {:ok, non_neg_integer()} | :error + def find_name_value(name, value) when is_binary(name) and is_binary(value) do + Map.fetch(@by_name_value, {name, value}) + end + + @doc """ + Find the lowest static table index that contains the given header name, + regardless of its associated value. + + This is useful during encoding when only the name (not the value) matches a + static table entry, allowing a name-reference with a literal value instead of + a full indexed representation. + + Returns `{:ok, index}` when the name exists in the static table, or `:error` + when no entry with that name is present. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name(":method") + {:ok, 15} + + iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name("x-custom-header") + :error + + """ + @spec find_name(binary()) :: {:ok, non_neg_integer()} | :error + def find_name(name) when is_binary(name) do + Map.fetch(@by_name, name) + end +end diff --git a/lib/ankh/protocol/http3/qpack/table.ex b/lib/ankh/protocol/http3/qpack/table.ex new file mode 100644 index 0000000..0863034 --- /dev/null +++ b/lib/ankh/protocol/http3/qpack/table.ex @@ -0,0 +1,272 @@ +defmodule Ankh.Protocol.HTTP3.QPACK.Table do + @moduledoc """ + QPACK dynamic table as described in RFC 9204, Section 2.2. + + ## Overview + + The QPACK dynamic table stores header field entries that accumulate over the + lifetime of an HTTP/3 connection. Unlike HPACK (RFC 7541), QPACK maintains + *separate* encoder and decoder tables that are synchronised via dedicated + unidirectional QUIC streams (the encoder stream and the decoder stream). This + separation allows the encoder to insert entries without blocking request + processing, at the cost of a more complex acknowledgement protocol. + + Because of this split, instances of this module represent only *one side* of + that pair. The `Ankh.Protocol.HTTP3.QPACK` encoder and decoder each own their own + `Ankh.Protocol.HTTP3.QPACK.Table` and keep them in sync by exchanging instructions on the + control streams. + + ## Capacity and the static-only mode + + When `max_capacity` is `0`—the default before the encoder sends a + `Set Dynamic Table Capacity` instruction—no entries can be inserted and every + header block must be encoded using static table references or literal field + representations. This is the mode currently used by `Ankh.Protocol.HTTP3.QPACK`. + + ## Entry ordering and size + + Entries are stored newest-first (the head of `entries` is the most recently + inserted entry), mirroring the HPACK convention. Each entry occupies: + + byte_size(name) + byte_size(value) + 32 bytes + + The 32-byte overhead accounts for estimated per-entry metadata, as defined in + RFC 9204 § 3.2.1 (the same rule as HPACK § 4.1). + + ## insert_count + + `insert_count` is a monotonically increasing counter of all insertions ever + made into the table. It is never decremented by evictions. QPACK uses it to + derive the **Required Insert Count** field of encoded header blocks, which + tells the decoder the minimum table state needed to decode a block + (RFC 9204 § 3.2.6). + + ## Relative indexing + + QPACK addresses dynamic table entries with a *relative* index that is always + interpreted with respect to a `base` value (the insertion count at encoding + time). Relative index `0` refers to the most recently inserted entry when the + header block was produced. See `lookup_relative/3` for the exact mapping. + """ + + @enforce_keys [:max_capacity] + defstruct entries: [], + size: 0, + max_capacity: 0, + insert_count: 0 + + @type t :: %__MODULE__{ + entries: [{binary(), binary()}], + size: non_neg_integer(), + max_capacity: non_neg_integer(), + insert_count: non_neg_integer() + } + + # Per-entry size overhead defined in RFC 9204 § 3.2.1 (= HPACK § 4.1). + @entry_overhead 32 + + # --------------------------------------------------------------------------- + # Public API + # --------------------------------------------------------------------------- + + @doc """ + Create a new, empty dynamic table with the given maximum capacity in bytes. + + When `max_capacity` is `0`, the table is effectively disabled: any attempt to + insert an entry will return `{:error, :too_large}` because no entry can ever + fit within a zero-byte budget. + + ## Examples + + iex> Ankh.Protocol.HTTP3.QPACK.Table.new(4096) + %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 4096, size: 0, insert_count: 0, entries: []} + + iex> Ankh.Protocol.HTTP3.QPACK.Table.new(0) + %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 0, size: 0, insert_count: 0, entries: []} + + """ + @spec new(non_neg_integer()) :: t() + def new(max_capacity) when is_integer(max_capacity) and max_capacity >= 0 do + %__MODULE__{max_capacity: max_capacity} + end + + @doc """ + Insert a new header field entry `{name, value}` into the dynamic table. + + The entry is prepended to `entries` (newest-first ordering). Before inserting, + the oldest entries are evicted one by one—from the tail of the list—until the + new entry can fit within `max_capacity`. + + Returns `{:error, :too_large}` when the entry's own size exceeds + `max_capacity`, meaning the table could never accommodate it regardless of + how many entries are evicted. + + ## Entry size formula + + byte_size(name) + byte_size(value) + 32 + + ## Examples + + iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) + iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "content-type", "text/plain") + iex> Ankh.Protocol.HTTP3.QPACK.Table.insert_count(table) + 1 + iex> Ankh.Protocol.HTTP3.QPACK.Table.size(table) + byte_size("content-type") + byte_size("text/plain") + 32 + + iex> Ankh.Protocol.HTTP3.QPACK.Table.insert(Ankh.Protocol.HTTP3.QPACK.Table.new(0), "x", "y") + {:error, :too_large} + + """ + @spec insert(t(), binary(), binary()) :: {:ok, t()} | {:error, :too_large} + def insert(%__MODULE__{max_capacity: max_capacity} = table, name, value) + when is_binary(name) and is_binary(value) do + entry_size = byte_size(name) + byte_size(value) + @entry_overhead + + if entry_size > max_capacity do + {:error, :too_large} + else + %__MODULE__{} = table = evict_to_fit(table, max_capacity - entry_size) + + {:ok, + %__MODULE__{ + table + | entries: [{name, value} | table.entries], + size: table.size + entry_size, + insert_count: table.insert_count + 1 + }} + end + end + + @doc """ + Look up a dynamic table entry by its QPACK relative index. + + ## Relative indexing + + QPACK's relative index `0` always refers to the most recently inserted entry + *at the time the header block was encoded*. The `base` argument captures that + moment: it is the `insert_count` value used as the encoding base (i.e. + Required Insert Count − 1, per RFC 9204 § 3.2.6). + + The absolute index of the entry is derived as: + + absolute_index = base - 1 - relative_index + + which is then mapped to a position in the newest-first `entries` list: + + list_position = insert_count - 1 - absolute_index + + Returns `{:ok, {name, value}}` when the entry exists and has not been evicted, + or `:error` when the index falls outside the live range of the table. + + ## Examples + + iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) + iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "a", "1") + iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "b", "2") + iex> # insert_count is 2; use base = 2 + iex> # relative 0 → absolute 1 → "b" (newest) + iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 0, 2) + {:ok, {"b", "2"}} + iex> # relative 1 → absolute 0 → "a" (oldest) + iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 1, 2) + {:ok, {"a", "1"}} + iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 2, 2) + :error + + """ + @spec lookup_relative(t(), non_neg_integer(), non_neg_integer()) :: + {:ok, {binary(), binary()}} | :error + def lookup_relative( + %__MODULE__{entries: entries, insert_count: insert_count}, + relative_index, + base + ) + when is_integer(relative_index) and relative_index >= 0 and + is_integer(base) and base >= 0 do + # Convert QPACK relative index + base to an absolute (0-based) insertion index. + absolute_index = base - 1 - relative_index + + # Map absolute index to a position in the newest-first entries list. + # Entry at list position p has absolute index (insert_count - 1 - p). + list_pos = insert_count - 1 - absolute_index + + if absolute_index >= 0 and list_pos >= 0 and list_pos < length(entries) do + {:ok, Enum.at(entries, list_pos)} + else + :error + end + end + + @doc """ + Return the current occupied size of the dynamic table in bytes. + """ + @spec size(t()) :: non_neg_integer() + def size(%__MODULE__{size: size}), do: size + + @doc """ + Return the maximum capacity of the dynamic table in bytes. + """ + @spec max_capacity(t()) :: non_neg_integer() + def max_capacity(%__MODULE__{max_capacity: max_capacity}), do: max_capacity + + @doc """ + Return the total number of entries ever inserted into the table. + + This counter is monotonically increasing and is never decremented by + evictions. It is used to derive the **Required Insert Count** for any header + block that references dynamic table entries (RFC 9204 § 3.2.6). + """ + @spec insert_count(t()) :: non_neg_integer() + def insert_count(%__MODULE__{insert_count: insert_count}), do: insert_count + + @doc """ + Change the maximum capacity of the dynamic table to `new_max` bytes. + + When `new_max` is smaller than the current occupied `size`, the oldest + entries are evicted until the table fits within the new limit. `insert_count` + is unaffected; evictions never reset it. + + ## Examples + + iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) + iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "content-type", "text/plain") + iex> table = Ankh.Protocol.HTTP3.QPACK.Table.resize(table, 0) + iex> Ankh.Protocol.HTTP3.QPACK.Table.size(table) + 0 + iex> Ankh.Protocol.HTTP3.QPACK.Table.insert_count(table) + 1 + + """ + @spec resize(t(), non_neg_integer()) :: t() + def resize(%__MODULE__{} = table, new_max) + when is_integer(new_max) and new_max >= 0 do + %__MODULE__{table | max_capacity: new_max} + |> evict_to_fit(new_max) + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + # Evict the oldest entries (tail of the newest-first list) one at a time + # until the occupied size is at or below `target_size`. + # + # When the table is already within budget, or there are no more entries to + # drop, this is a no-op. + @spec evict_to_fit(t(), non_neg_integer()) :: t() + defp evict_to_fit(%__MODULE__{size: size} = table, target_size) + when size <= target_size, + do: table + + defp evict_to_fit(%__MODULE__{entries: []} = table, _target_size), do: table + + defp evict_to_fit(%__MODULE__{entries: entries, size: size} = table, target_size) do + # Entries are newest-first; the oldest entry to evict is at the tail. + {oldest_name, oldest_value} = List.last(entries) + oldest_entry_size = byte_size(oldest_name) + byte_size(oldest_value) + @entry_overhead + + %__MODULE__{table | entries: :lists.droplast(entries), size: size - oldest_entry_size} + |> evict_to_fit(target_size) + end +end diff --git a/lib/ankh/protocol/http3/stream.ex b/lib/ankh/protocol/http3/stream.ex new file mode 100644 index 0000000..523d179 --- /dev/null +++ b/lib/ankh/protocol/http3/stream.ex @@ -0,0 +1,406 @@ +defmodule Ankh.Protocol.HTTP3.Stream do + @moduledoc """ + Per-QUIC-stream state tracker for HTTP/3 (RFC 9114). + + Each QUIC stream used by the HTTP/3 layer is represented by one + `#{__MODULE__}` struct. The struct travels through a well-defined lifecycle: + + ## Lifecycle + + ### Creation + + Use `new_request/1` when the local side opens a new bidirectional request + stream (client-initiated). Use `new_incoming/2` when a stream arrives from + the remote peer; pass `unidirectional? = false` for a bidirectional request + stream and `unidirectional? = true` for a unidirectional stream whose kind + is not yet known. + + Both functions assign a fresh Elixir `reference()` (via `make_ref/0`) that + can be used to correlate the stream across the rest of the connection. + + ### Unidirectional stream kind identification + + Unidirectional streams start with kind `:unknown_unidirectional`. When the + first byte of the stream body is received, call `identify_kind/2` with that + byte to resolve the kind: + + * `0x00` → `:control` (HTTP/3 control stream, RFC 9114 §6.2.1) + * `0x01` → `:push` (server-push stream, RFC 9114 §4.6) + * `0x02` → `:qpack_encoder` (QPACK encoder stream, RFC 9204 §4.2) + * `0x03` → `:qpack_decoder` (QPACK decoder stream, RFC 9204 §4.2) + * anything else → `:unknown_unidirectional` (must be ignored per RFC 9114 §6.2) + + `identify_kind/2` is a no-op when the stream already has kind `:request`. + + ### Incoming data + + Raw bytes received from the QUIC layer are accumulated with `append/2`. The + frame parser (see `Ankh.Protocol.HTTP3.Frame`) should consume the buffer and + pass any unconsumed remainder back to `consume_buffer/2` so it is retained + for the next delivery. + + ### FIN / half-close handling + + QUIC streams support independent half-closes on each side: + + * `remote_fin/1` — call when the peer signals `peer_send_shutdown` (it has + finished sending). Transitions `:open → :half_closed_remote` and + `:half_closed_local → :closed`. + * `local_fin/1` — call after sending a FIN (e.g. via + `:quicer.shutdown_stream/1`). Transitions `:open → :half_closed_local` + and `:half_closed_remote → :closed`. + + States that are already past the relevant half-close are left unchanged. + + ### Terminal state + + `closed?/1` returns `true` once the stream reaches `:closed`. + `remote_open?/1` returns `true` while the remote side has not yet sent a FIN + (states `:open` and `:half_closed_local`), indicating that more data or + headers may still arrive. + + ## Frame parsing + + This module deliberately does **not** parse HTTP/3 frames. Frame encoding + and decoding belong in `Ankh.Protocol.HTTP3.Frame`. + """ + + # --------------------------------------------------------------------------- + # Types + # --------------------------------------------------------------------------- + + @typedoc """ + HTTP/3 stream states, mirroring the QUIC stream half-close model. + + * `:idle` — stream struct created but not yet active. + * `:open` — both sides may send and receive. + * `:half_closed_local` — local side has sent FIN; remote may still send. + * `:half_closed_remote` — remote side has sent FIN; local may still send. + * `:closed` — both sides have finished; no further data. + """ + @type state :: + :idle + | :open + | :half_closed_local + | :half_closed_remote + | :closed + + @typedoc """ + Categorises a QUIC stream by its HTTP/3 role. + + * `:request` — bidirectional request/response stream + (RFC 9114 §6.1). + * `:control` — unidirectional HTTP/3 control stream, + stream-type byte `0x00` (RFC 9114 §6.2.1). + * `:push` — unidirectional server-push stream, + stream-type byte `0x01` (RFC 9114 §4.6). + * `:qpack_encoder` — unidirectional QPACK encoder stream, + stream-type byte `0x02` (RFC 9204 §4.2). + * `:qpack_decoder` — unidirectional QPACK decoder stream, + stream-type byte `0x03` (RFC 9204 §4.2). + * `:unknown_unidirectional` — unidirectional stream whose type byte has + not yet been received or is not a + recognised value. + * `:unknown` — kind not yet determined (initial value). + """ + @type stream_kind :: + :request + | :control + | :qpack_encoder + | :qpack_decoder + | :push + | :unknown_unidirectional + | :unknown + + @typedoc "Per-QUIC-stream HTTP/3 state struct." + @type t :: %__MODULE__{ + handle: :quicer.stream_handle() | nil, + reference: reference() | nil, + state: state(), + kind: stream_kind(), + buffer: binary(), + recv_headers: boolean(), + send_headers: boolean() + } + + defstruct handle: nil, + reference: nil, + state: :idle, + kind: :unknown, + buffer: <<>>, + recv_headers: false, + send_headers: false + + # --------------------------------------------------------------------------- + # Construction + # --------------------------------------------------------------------------- + + @doc """ + Creates a new client-opened request stream for the given QUIC stream handle. + + The returned struct has: + + * `kind` = `:request` + * `state` = `:open` + * `reference` = a fresh `reference()` from `make_ref/0` + + ## Example + + iex> stream = #{__MODULE__}.new_request(some_handle) + iex> stream.kind + :request + iex> stream.state + :open + """ + @spec new_request(handle :: :quicer.stream_handle()) :: t() + def new_request(handle) do + %__MODULE__{ + handle: handle, + reference: make_ref(), + state: :open, + kind: :request + } + end + + @doc """ + Creates a new server-received (incoming) stream for the given QUIC stream handle. + + When `unidirectional?` is `true`, the kind is set to `:unknown_unidirectional` + because the stream-type byte has not yet arrived. When `unidirectional?` is + `false`, the stream is a bidirectional request stream and the kind is set to + `:request` immediately. + + In both cases the state is `:open` and a fresh `reference()` is assigned. + + ## Examples + + iex> stream = #{__MODULE__}.new_incoming(some_handle, false) + iex> stream.kind + :request + + iex> stream = #{__MODULE__}.new_incoming(some_handle, true) + iex> stream.kind + :unknown_unidirectional + """ + @spec new_incoming(handle :: :quicer.stream_handle(), unidirectional? :: boolean()) :: t() + def new_incoming(handle, false = _unidirectional?) do + %__MODULE__{ + handle: handle, + reference: make_ref(), + state: :open, + kind: :request + } + end + + def new_incoming(handle, true = _unidirectional?) do + %__MODULE__{ + handle: handle, + reference: make_ref(), + state: :open, + kind: :unknown_unidirectional + } + end + + # --------------------------------------------------------------------------- + # Kind identification + # --------------------------------------------------------------------------- + + @doc """ + Resolves the kind of a unidirectional stream from its first stream-type byte. + + The mapping from wire byte to kind atom is defined by RFC 9114 §6.2 and + RFC 9204 §4.2: + + * `0x00` → `:control` + * `0x01` → `:push` + * `0x02` → `:qpack_encoder` + * `0x03` → `:qpack_decoder` + * other → `:unknown_unidirectional` (extension / reserved stream type) + + This function is a **no-op** when the stream kind is already `:request`; + request streams are bidirectional and do not carry a stream-type prefix. + + ## Examples + + iex> stream = #{__MODULE__}.new_incoming(handle, true) + iex> #{__MODULE__}.identify_kind(stream, 0x00).kind + :control + + iex> #{__MODULE__}.identify_kind(stream, 0x02).kind + :qpack_encoder + + iex> #{__MODULE__}.identify_kind(stream, 0xFF).kind + :unknown_unidirectional + """ + @spec identify_kind(t(), stream_type_byte :: non_neg_integer()) :: t() + def identify_kind(%__MODULE__{kind: :request} = stream, _stream_type_byte), do: stream + + def identify_kind(%__MODULE__{} = stream, stream_type_byte) do + kind = + case stream_type_byte do + 0x00 -> :control + 0x01 -> :push + 0x02 -> :qpack_encoder + 0x03 -> :qpack_decoder + _ -> :unknown_unidirectional + end + + %{stream | kind: kind} + end + + # --------------------------------------------------------------------------- + # Buffer management + # --------------------------------------------------------------------------- + + @doc """ + Appends `data` to the stream's receive buffer. + + Call this each time the QUIC layer delivers bytes for this stream. The + accumulated buffer is then available for the frame parser to consume. + + ## Example + + iex> stream = #{__MODULE__}.new_request(handle) + iex> stream = #{__MODULE__}.append(stream, <<0x01, 0x05>>) + iex> stream.buffer + <<0x01, 0x05>> + iex> stream = #{__MODULE__}.append(stream, <<"hello">>) + iex> stream.buffer + <<0x01, 0x05, "hello">> + """ + @spec append(t(), binary()) :: t() + def append(%__MODULE__{buffer: buffer} = stream, data) when is_binary(data) do + %{stream | buffer: buffer <> data} + end + + @doc """ + Replaces the stream buffer with `remainder`. + + Call this after the frame parser has consumed one or more complete frames + from `stream.buffer`, passing whatever bytes were not consumed. This keeps + partial frames available for the next delivery without re-appending the + already-parsed bytes. + + ## Example + + iex> stream = #{__MODULE__}.append(stream, <<0x00, 0x03, "abc", 0xFF>>) + iex> stream = #{__MODULE__}.consume_buffer(stream, <<0xFF>>) + iex> stream.buffer + <<0xFF>> + """ + @spec consume_buffer(t(), binary()) :: t() + def consume_buffer(%__MODULE__{} = stream, remainder) when is_binary(remainder) do + %{stream | buffer: remainder} + end + + # --------------------------------------------------------------------------- + # FIN / half-close transitions + # --------------------------------------------------------------------------- + + @doc """ + Records that the remote peer has finished sending on this stream. + + This is typically triggered by a `{:quic, :peer_send_shutdown, …}` message + from the quicer NIF. + + State transitions: + + * `:open` → `:half_closed_remote` + * `:half_closed_local` → `:closed` + * all other states → unchanged + + ## Examples + + iex> stream = #{__MODULE__}.new_request(handle) + iex> #{__MODULE__}.remote_fin(stream).state + :half_closed_remote + + iex> stream = %{stream | state: :half_closed_local} + iex> #{__MODULE__}.remote_fin(stream).state + :closed + """ + @spec remote_fin(t()) :: t() + def remote_fin(%__MODULE__{state: :open} = stream), + do: %{stream | state: :half_closed_remote} + + def remote_fin(%__MODULE__{state: :half_closed_local} = stream), + do: %{stream | state: :closed} + + def remote_fin(%__MODULE__{} = stream), do: stream + + @doc """ + Records that the local side has finished sending on this stream. + + Call this after successfully writing a FIN to the QUIC layer (e.g. via + `:quicer.shutdown_stream/1` or the `fin` flag on the last `:quicer.send/3`). + + State transitions: + + * `:open` → `:half_closed_local` + * `:half_closed_remote` → `:closed` + * all other states → unchanged + + ## Examples + + iex> stream = #{__MODULE__}.new_request(handle) + iex> #{__MODULE__}.local_fin(stream).state + :half_closed_local + + iex> stream = %{stream | state: :half_closed_remote} + iex> #{__MODULE__}.local_fin(stream).state + :closed + """ + @spec local_fin(t()) :: t() + def local_fin(%__MODULE__{state: :open} = stream), + do: %{stream | state: :half_closed_local} + + def local_fin(%__MODULE__{state: :half_closed_remote} = stream), + do: %{stream | state: :closed} + + def local_fin(%__MODULE__{} = stream), do: stream + + # --------------------------------------------------------------------------- + # State predicates + # --------------------------------------------------------------------------- + + @doc """ + Returns `true` when the stream has reached the `:closed` state. + + A closed stream has had both its send and receive sides terminated; no + further data or frames should be processed on it. + + ## Example + + iex> stream = #{__MODULE__}.new_request(handle) + iex> #{__MODULE__}.closed?(stream) + false + iex> stream = stream |> #{__MODULE__}.remote_fin() |> #{__MODULE__}.local_fin() + iex> #{__MODULE__}.closed?(stream) + true + """ + @spec closed?(t()) :: boolean() + def closed?(%__MODULE__{state: :closed}), do: true + def closed?(%__MODULE__{}), do: false + + @doc """ + Returns `true` when the remote side of the stream is still open. + + The remote side is considered open in states `:open` and + `:half_closed_local`, meaning the peer has not yet sent a FIN and may still + deliver headers, data, or other frames. + + ## Examples + + iex> stream = #{__MODULE__}.new_request(handle) + iex> #{__MODULE__}.remote_open?(stream) + true + + iex> #{__MODULE__}.remote_open?(#{__MODULE__}.remote_fin(stream)) + false + """ + @spec remote_open?(t()) :: boolean() + def remote_open?(%__MODULE__{state: state}) when state in [:open, :half_closed_local], + do: true + + def remote_open?(%__MODULE__{}), do: false +end diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex new file mode 100644 index 0000000..801c649 --- /dev/null +++ b/lib/ankh/transport/quic.ex @@ -0,0 +1,452 @@ +defmodule Ankh.Transport.QUIC do + @moduledoc """ + QUIC transport implementation using the `quicer` library. + + This transport uses `quicer` (https://github.com/emqx/quic), an Erlang/Elixir + binding for MsQuic — Microsoft's cross-platform QUIC implementation. + + ## Differences from TCP/TLS + + Unlike TCP or TLS transports, which expose a single bytestream socket, QUIC is + inherently multiplexed. The QUIC transport therefore manages two distinct handles: + + * A **connection handle** (`t:connection/0`) — the underlying QUIC connection. + * A **stream handle** (`t:stream/0`) — a single bidirectional QUIC stream used + for data exchange within that connection. + + For the client path, both handles are established during `connect/4`. For the + server path, the connection handle is set via `new/2` (typically after accepting + a raw QUIC connection from a listener), and the stream handle is obtained by + calling `accept/2`, which waits for the remote peer to open a stream. + + ## Active mode and message handling + + QUIC streams are kept in `active: :once` mode, mirroring the behaviour of the + TCP and TLS transports. After each `{:quic, data, stream, props}` message is + processed by `handle_msg/2` the stream is immediately re-armed for the next + delivery. Callers should therefore drive the transport through `handle_msg/2` + rather than `recv/3` when operating in message-passing mode. + + ## ALPN + + The default ALPN token is `"h3"` (HTTP/3). This can be overridden via the + `:alpn` option passed to `connect/4`. + + ## Dependencies + + Requires the `quicer` optional dependency to be present **and started** (i.e. + the `:quicer` OTP application must be running). + """ + + require Logger + + alias Ankh.Transport + + @typedoc "QUIC connection handle (opaque reference returned by quicer)" + @type connection :: reference() + + @typedoc "QUIC stream handle (opaque reference returned by quicer)" + @type stream :: reference() + + @type t :: %__MODULE__{ + connection: connection() | nil, + stream: stream() | nil + } + + defstruct connection: nil, stream: nil + + # --------------------------------------------------------------------------- + # HTTP/3 stream-management helpers + # + # These functions are NOT part of the Ankh.Transport protocol. They are + # public module-level functions that the Ankh.Protocol.HTTP3 implementation + # uses to manage the multiple concurrent QUIC streams that HTTP/3 requires, + # without reaching into the :quicer NIF directly. + # --------------------------------------------------------------------------- + + @doc """ + Opens a new bidirectional QUIC stream on the connection and returns a + transport struct with that stream handle set. + + Used by `Ankh.Protocol.HTTP3.request/2` to obtain a fresh stream for each + HTTP/3 request. The stream is armed with `active: :once` so the first + incoming message is delivered automatically. + """ + @spec open_stream(t()) :: {:ok, t()} | {:error, any()} + def open_stream(%__MODULE__{connection: conn} = transport) when not is_nil(conn) do + case :quicer.start_stream(conn, %{active: :once}) do + {:ok, handle} -> {:ok, %{transport | stream: handle}} + {:error, _} = error -> error + end + end + + @doc """ + Opens a new **unidirectional** QUIC stream on the connection and immediately + writes `preface` on it. + + Used by the HTTP/3 protocol to set up the control stream immediately after + a connection is established. The stream is opened in passive mode (`active: + false`) because it is write-only from this endpoint's perspective; data + arriving on peer-initiated unidirectional streams is delivered via the + `:new_stream` / binary-data QUIC messages instead. + """ + @spec open_unidirectional_stream(t(), iodata()) :: :ok | {:error, any()} + def open_unidirectional_stream(%__MODULE__{connection: conn}, preface) + when not is_nil(conn) do + # open_flag: 1 is QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL + with {:ok, handle} <- :quicer.start_stream(conn, %{open_flag: 1, active: false}) do + case :quicer.send(handle, IO.iodata_to_binary(preface)) do + {:ok, _bytes} -> :ok + {:error, _} = error -> error + end + end + end + + @doc """ + Gracefully shuts down the **send side** of the stored stream (QUIC FIN), + signalling to the peer that no more data will be sent. + + This does **not** close the connection. Used after sending the last frame + on a request or response stream so the peer knows the message is complete. + """ + @spec shutdown_stream(t()) :: :ok | {:error, any()} + def shutdown_stream(%__MODULE__{stream: stream}) when not is_nil(stream) do + :quicer.shutdown_stream(stream, 5_000) + end + + @doc """ + Re-arms `active: :once` mode on the stored stream after a single QUIC data + message has been delivered. + + Must be called inside the `stream/2` handler each time a + `{:quic, data, stream_handle, props}` message is processed, so that the + next incoming message is delivered to the owning process. + """ + @spec rearm_stream(t()) :: :ok + def rearm_stream(%__MODULE__{stream: stream}) when not is_nil(stream) do + case :quicer.setopt(stream, :active, :once) do + :ok -> + :ok + + {:error, reason} -> + Logger.debug("QUIC stream re-arm error: #{inspect(reason)}") + :ok + end + end + + @doc """ + Closes the QUIC **connection** without first closing a specific stream. + + Used by `Ankh.Protocol.HTTP3.error/1` to tear down the whole connection + when a connection-level error is detected. + """ + @spec close_connection(t()) :: :ok | {:error, any()} + def close_connection(%__MODULE__{connection: conn}) when not is_nil(conn) do + case :quicer.close_connection(conn) do + :ok -> :ok + {:error, _} = error -> error + end + end + + defimpl Transport do + # Default ALPN for HTTP/3. + @default_alpn ["h3"] + + # Stream options used when opening or accepting a stream. + # `active: :once` delivers exactly one message then disarms, matching + # the behaviour of the TCP/TLS transports. + @default_stream_opts [active: :once] + + # --------------------------------------------------------------------------- + # new/2 + # --------------------------------------------------------------------------- + + @doc """ + Initialises the transport from an existing QUIC handle. + + Accepts either: + + * A bare `connection_handle()` — used on the server path where the + connection has already been accepted from a listener. The stream + field is left `nil` and must be populated via `accept/2` before + data can flow. + + * A `{connection_handle(), stream_handle()}` tuple — used when both + handles are already known (e.g. when handing an established session + off to another process). + """ + def new(%@for{} = transport, {connection, stream}) + when is_reference(connection) and is_reference(stream) do + {:ok, %{transport | connection: connection, stream: stream}} + end + + def new(%@for{} = transport, connection) when is_reference(connection) do + {:ok, %{transport | connection: connection}} + end + + # --------------------------------------------------------------------------- + # connect/4 + # --------------------------------------------------------------------------- + + @doc """ + Opens a QUIC connection to the given `uri`. + + Returns a transport with only the `connection` field set (`stream: nil`). + Streams are not opened automatically; use `Ankh.Transport.QUIC.open_stream/1` + or `Ankh.Transport.QUIC.open_unidirectional_stream/2` at the protocol layer + to create streams on demand. + + Supported options (all optional): + + * `:alpn` — list of ALPN tokens; defaults to `[~c"h3"]`. + Must be charlists (Erlang strings), not Elixir binaries. + * `:verify` — peer certificate verification; defaults to `:verify_peer`. + * `:cacertfile` — path to a CA certificate bundle. + * `:certfile` — path to a client certificate (mTLS). + * `:keyfile` — path to the private key for the client certificate. + * `:password` — passphrase for an encrypted private key. + * `:peer_unidi_stream_count` — number of unidirectional streams the remote + peer is allowed to open; useful for HTTP/3 control/QPACK streams. + """ + def connect(%@for{} = transport, %URI{host: host, port: port}, timeout, options) do + hostname = String.to_charlist(host) + alpn = Keyword.get(options, :alpn, @default_alpn) + verify = Keyword.get(options, :verify, :verify_peer) + + conn_opts = + [alpn: alpn, verify: verify] + |> Keyword.merge( + Keyword.take(options, [ + :cacertfile, + :certfile, + :keyfile, + :password, + :peer_unidi_stream_count, + :peer_bidi_stream_count + ]) + ) + |> Map.new() + + with {:ok, conn} <- :quicer.connect(hostname, port || 443, conn_opts, timeout) do + {:ok, %{transport | connection: conn}} + end + end + + # --------------------------------------------------------------------------- + # accept/2 + # --------------------------------------------------------------------------- + + @doc """ + Accepts an inbound QUIC stream on an already-established connection. + + The connection handle must have been set beforehand via `new/2`. This call + blocks until the remote peer opens a stream or an error occurs. + + Any additional `options` are merged with the default stream options and + forwarded to `:quicer.accept_stream/2`. + """ + def accept(%@for{connection: conn} = transport, options) when not is_nil(conn) do + stream_opts = @default_stream_opts |> Keyword.merge(options) |> Map.new() + + with {:ok, stream} <- :quicer.accept_stream(conn, stream_opts) do + {:ok, %{transport | stream: stream}} + end + end + + def accept(%@for{connection: nil}, _options) do + {:error, :no_connection} + end + + # --------------------------------------------------------------------------- + # send/2 + # --------------------------------------------------------------------------- + + @doc """ + Sends `data` over the QUIC stream synchronously. + + The data is converted to a binary via `IO.iodata_to_binary/1` before being + handed off to `:quicer.send/2`, which blocks until the send is acknowledged + by the transport layer. + """ + def send(%@for{stream: stream}, data) when not is_nil(stream) do + case :quicer.send(stream, IO.iodata_to_binary(data)) do + {:ok, _bytes_sent} -> :ok + {:error, _} = error -> error + end + end + + def send(%@for{stream: nil}, _data) do + {:error, :no_stream} + end + + # --------------------------------------------------------------------------- + # recv/3 + # --------------------------------------------------------------------------- + + @doc """ + Passively reads up to `size` bytes from the QUIC stream. + + Pass `size = 0` to retrieve all currently available data in the receive + buffer (the quicer default behaviour for a zero-length read). + + The `timeout` argument is accepted for interface compatibility but is not + forwarded to quicer; quicer manages its own internal timeouts. + """ + def recv(%@for{stream: stream}, size, _timeout) when not is_nil(stream) do + :quicer.recv(stream, size) + end + + def recv(%@for{stream: nil}, _size, _timeout) do + {:error, :no_stream} + end + + # --------------------------------------------------------------------------- + # close/1 + # --------------------------------------------------------------------------- + + @doc """ + Gracefully closes the QUIC stream and then the underlying connection. + + The stream is shut down with a FIN (graceful half-close of the send side) + before the connection is closed. Errors from individual close calls are + logged but do not prevent the other handle from being closed. + """ + def close(%@for{connection: conn, stream: stream} = transport) do + if not is_nil(stream) do + case :quicer.close_stream(stream) do + :ok -> + :ok + + {:error, reason} = error -> + Logger.debug("QUIC stream close error: #{inspect(reason)}") + error + end + end + + if not is_nil(conn) do + case :quicer.close_connection(conn) do + :ok -> + :ok + + {:error, reason} = error -> + Logger.debug("QUIC connection close error: #{inspect(reason)}") + error + end + end + + {:ok, %{transport | connection: nil, stream: nil}} + end + + # --------------------------------------------------------------------------- + # handle_msg/2 + # --------------------------------------------------------------------------- + + @doc """ + Handles asynchronous messages delivered by the quicer NIF. + + ## Handled messages + + * `{:quic, data, stream, props}` — binary data arrived on our stream. + The stream is re-armed for the next `active: :once` delivery before + returning `{:ok, data}`. + * `{:quic, :peer_send_shutdown, stream, _}` — the remote peer has + half-closed its send side (sent a FIN). + * `{:quic, :peer_send_aborted, stream, _}` — the remote peer aborted + its send side. + * `{:quic, :stream_closed, stream, _}` — the stream has been fully + closed by both sides. + * `{:quic, :transport_shutdown, conn, _}` — the QUIC connection was + shut down by the transport (e.g. idle timeout, network loss). + * `{:quic, :closed, conn, _}` — the QUIC connection was closed. + + All other messages return `{:other, msg}` so the caller can handle them. + """ + + # HTTP/3 passthrough mode: when no specific stream is stored, the QUIC + # transport is operating as a bare connection handle on behalf of the + # HTTP/3 protocol layer. Pass every QUIC message through as-is so that + # `Ankh.Protocol.HTTP3.stream/2` can inspect the stream handle and + # dispatch to the correct per-request stream state machine. + def handle_msg(%@for{stream: nil}, {:quic, _, _, _} = msg), do: {:ok, msg} + + # Binary data on our stream — re-arm active mode and return the payload. + def handle_msg(%@for{stream: stream}, {:quic, data, stream, _props}) + when is_binary(data) and not is_nil(stream) do + case :quicer.setopt(stream, :active, :once) do + :ok -> + {:ok, data} + + {:error, reason} -> + Logger.debug("QUIC stream re-arm error: #{inspect(reason)}") + {:ok, data} + end + end + + # Remote peer finished sending (graceful FIN). + def handle_msg(%@for{stream: stream}, {:quic, :peer_send_shutdown, stream, _props}) + when not is_nil(stream) do + {:error, :closed} + end + + # Remote peer aborted its send side. + def handle_msg(%@for{stream: stream}, {:quic, :peer_send_aborted, stream, _error_code}) + when not is_nil(stream) do + {:error, :closed} + end + + # Stream has been fully closed. + def handle_msg(%@for{stream: stream}, {:quic, :stream_closed, stream, _props}) + when not is_nil(stream) do + {:error, :closed} + end + + # Transport-level shutdown (e.g. idle timeout, network error). + def handle_msg(%@for{connection: conn}, {:quic, :transport_shutdown, conn, props}) + when not is_nil(conn) do + Logger.debug("QUIC transport shutdown: #{inspect(props)}") + {:error, :closed} + end + + # Connection closed. + def handle_msg(%@for{connection: conn}, {:quic, :closed, conn, _props}) + when not is_nil(conn) do + {:error, :closed} + end + + # Unrelated message — pass it through for the caller to handle. + def handle_msg(_quic, msg), do: {:other, msg} + + # --------------------------------------------------------------------------- + # negotiated_protocol/1 + # --------------------------------------------------------------------------- + + @doc """ + Returns the ALPN protocol negotiated during the QUIC handshake. + + Delegates to `:quicer.negotiated_protocol/1` on the connection handle. + Returns `{:error, :protocol_not_negotiated}` if no connection is present. + """ + # quicer's negotiated_protocol/1 delegates to quicer_nif:getopt/3, a NIF + # whose Dialyzer success-typing resolves to only {:error, atom()}. The + # quicer @spec correctly declares {:ok, binary()} | {:error, any()}, but + # Dialyzer's conservative NIF analysis cannot see the {:ok, _} branch as + # reachable. This is a confirmed false positive; suppress it here rather + # than in the ignore-warnings file because Erlex fails to parse the raw + # Erlang type in the warning args, causing dialyxir's filter to silently + # skip the entry. + @dialyzer {:nowarn_function, [negotiated_protocol: 1]} + def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn) do + case :quicer.negotiated_protocol(conn) do + {:ok, protocol} -> + {:ok, protocol} + + {:error, _reason} -> + {:error, :protocol_not_negotiated} + end + end + + def negotiated_protocol(%@for{connection: nil}) do + {:error, :protocol_not_negotiated} + end + end +end diff --git a/mix.exs b/mix.exs index fb13dca..c0519d9 100644 --- a/mix.exs +++ b/mix.exs @@ -38,6 +38,7 @@ defmodule Ankh.Mixfile do {:castore, "~> 1.0"}, {:hpax, "~> 1.0"}, {:plug, "~> 1.0"}, + {:quicer, git: "https://github.com/emqx/quic.git", tag: "0.2.16", optional: true}, {:credo, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, "~> 0.40.0", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false} @@ -49,6 +50,7 @@ defmodule Ankh.Mixfile do groups_for_modules: [ HTTP1: [~r/^Ankh\.Protocol\.HTTP1\.*/], HTTP2: [~r/^Ankh\.Protocol\.HTTP2\.*/], + HTTP3: [~r/^Ankh\.Protocol\.HTTP3\.*/], Internals: [~r/^Ankh\.Protocol$/, ~r/^Ankh\.Transport$/], Transports: [~r/Ankh\.Transport\.*/] ] diff --git a/mix.lock b/mix.lock index 11ecf22..7ec1539 100644 --- a/mix.lock +++ b/mix.lock @@ -16,5 +16,8 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "quic": {:hex, :quic, "1.6.5", "28b7d49c1732b2de3861701bb03ae7798537240552370ac49f0d139d2ea8f621", [:rebar3], [], "hexpm", "de1a88972c33201a50d1a17c8c4a14528bd1d8f25ef705897d680ae312d0aa78"}, + "quicer": {:git, "https://github.com/emqx/quic.git", "b097389558e425b0ae360d3a25274bda5dcefa71", [tag: "0.2.16"]}, + "snabbkaffe": {:hex, :snabbkaffe, "1.0.10", "9be2f54f61fc6862391b666b2b5b76c3fa53598e2989a17cef1b48cf347a8a63", [:rebar3], [], "hexpm", "70a98df36ae756908d55b5770891d443d63c903833e3e87d544036e13d4fac26"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, } diff --git a/test/ankh/protocol/http3/frame_test.exs b/test/ankh/protocol/http3/frame_test.exs new file mode 100644 index 0000000..606911b --- /dev/null +++ b/test/ankh/protocol/http3/frame_test.exs @@ -0,0 +1,546 @@ +defmodule Ankh.Protocol.HTTP3.FrameTest do + use ExUnit.Case, async: true + + import Bitwise + + doctest Ankh.Protocol.HTTP3.Frame + + alias Ankh.Protocol.HTTP3.Frame + + alias Ankh.Protocol.HTTP3.Frame.{ + CancelPush, + Data, + GoAway, + Headers, + MaxPushId, + PushPromise, + Settings + } + + # --------------------------------------------------------------------------- + # VLI encoding + # --------------------------------------------------------------------------- + + describe "encode_vli/1" do + test "encodes zero as a single byte" do + assert Frame.encode_vli(0) == <<0x00>> + end + + test "encodes the 1-byte boundary value 63" do + assert Frame.encode_vli(63) == <<63>> + end + + test "encodes 64 in two bytes with 01 prefix" do + <> = Frame.encode_vli(64) + assert msb >>> 6 == 1 + end + + test "encodes 16_383 in two bytes" do + encoded = Frame.encode_vli(16_383) + assert byte_size(encoded) == 2 + assert {:ok, 16_383, <<>>} = Frame.decode_vli(encoded) + end + + test "encodes 16_384 in four bytes with 10 prefix" do + encoded = Frame.encode_vli(16_384) + assert byte_size(encoded) == 4 + <> = encoded + assert msb >>> 6 == 2 + end + + test "encodes 1_073_741_823 in four bytes" do + encoded = Frame.encode_vli(1_073_741_823) + assert byte_size(encoded) == 4 + assert {:ok, 1_073_741_823, <<>>} = Frame.decode_vli(encoded) + end + + test "encodes 1_073_741_824 in eight bytes with 11 prefix" do + encoded = Frame.encode_vli(1_073_741_824) + assert byte_size(encoded) == 8 + <> = encoded + assert msb >>> 6 == 3 + end + + test "round-trips a large 8-byte value" do + n = 4_611_686_018_427_387_903 + encoded = Frame.encode_vli(n) + assert byte_size(encoded) == 8 + assert {:ok, ^n, <<>>} = Frame.decode_vli(encoded) + end + + test "always uses the smallest encoding" do + assert byte_size(Frame.encode_vli(0)) == 1 + assert byte_size(Frame.encode_vli(63)) == 1 + assert byte_size(Frame.encode_vli(64)) == 2 + assert byte_size(Frame.encode_vli(16_383)) == 2 + assert byte_size(Frame.encode_vli(16_384)) == 4 + assert byte_size(Frame.encode_vli(1_073_741_823)) == 4 + assert byte_size(Frame.encode_vli(1_073_741_824)) == 8 + end + end + + # --------------------------------------------------------------------------- + # VLI decoding + # --------------------------------------------------------------------------- + + describe "decode_vli/1" do + test "decodes a 1-byte integer" do + assert {:ok, 7, <<>>} = Frame.decode_vli(<<7>>) + end + + test "decodes zero" do + assert {:ok, 0, <<>>} = Frame.decode_vli(<<0>>) + end + + test "decodes 63 (max 1-byte)" do + assert {:ok, 63, <<>>} = Frame.decode_vli(<<63>>) + end + + test "decodes a 2-byte integer and returns remainder" do + assert {:ok, 494, "rest"} = Frame.decode_vli(<<0x41, 0xEE, "rest">>) + end + + test "decodes a 4-byte integer" do + encoded = Frame.encode_vli(100_000) + assert {:ok, 100_000, <<>>} = Frame.decode_vli(encoded) + end + + test "decodes an 8-byte integer" do + encoded = Frame.encode_vli(2_000_000_000_000) + assert {:ok, 2_000_000_000_000, <<>>} = Frame.decode_vli(encoded) + end + + test "returns :incomplete for empty binary" do + assert {:error, :incomplete} = Frame.decode_vli(<<>>) + end + + test "returns :incomplete when 2-byte integer is truncated" do + assert {:error, :incomplete} = Frame.decode_vli(<<0x40>>) + end + + test "returns :incomplete when 4-byte integer is truncated" do + assert {:error, :incomplete} = Frame.decode_vli(<<0x80, 0x00, 0x00>>) + end + + test "returns :incomplete when 8-byte integer is truncated" do + assert {:error, :incomplete} = Frame.decode_vli(<<0xC0, 0x00, 0x00>>) + end + + test "round-trips all boundary values" do + for n <- [0, 63, 64, 16_383, 16_384, 1_073_741_823, 1_073_741_824] do + encoded = Frame.encode_vli(n) + + assert {:ok, ^n, <<>>} = Frame.decode_vli(encoded), + "round-trip failed for #{n}" + end + end + end + + # --------------------------------------------------------------------------- + # Frame encoding — struct-based API + # --------------------------------------------------------------------------- + + describe "encode/1" do + test "encodes a DATA frame" do + frame = %Data{payload: %Data.Payload{data: "hello"}} + assert {:ok, updated, iodata} = Frame.encode(frame) + assert IO.iodata_to_binary(iodata) == <<0x00, 0x05, "hello">> + assert updated.length == 5 + assert updated.type == 0x0 + end + + test "encodes a HEADERS frame" do + frame = %Headers{payload: %Headers.Payload{hbf: "hdr"}} + assert {:ok, updated, iodata} = Frame.encode(frame) + assert IO.iodata_to_binary(iodata) == <<0x01, 0x03, "hdr">> + assert updated.length == 3 + assert updated.type == 0x1 + end + + test "encodes a SETTINGS frame with [{1, 0}, {7, 0}]" do + frame = %Settings{payload: %Settings.Payload{settings: [{1, 0}, {7, 0}]}} + assert {:ok, updated, iodata} = Frame.encode(frame) + assert IO.iodata_to_binary(iodata) == <<0x04, 0x04, 0x01, 0x00, 0x07, 0x00>> + assert updated.length == 4 + assert updated.type == 0x4 + end + + test "encodes a SETTINGS frame with empty settings" do + frame = %Settings{payload: %Settings.Payload{settings: []}} + assert {:ok, updated, iodata} = Frame.encode(frame) + assert IO.iodata_to_binary(iodata) == <<0x04, 0x00>> + assert updated.length == 0 + end + + test "encodes a CANCEL_PUSH frame with push_id: 42" do + frame = %CancelPush{payload: %CancelPush.Payload{push_id: 42}} + assert {:ok, updated, iodata} = Frame.encode(frame) + bin = IO.iodata_to_binary(iodata) + # type 0x03, VLI length, VLI push_id + assert <<0x03, _len, _push_id_vli::binary>> = bin + # push_id 42 < 64 so encodes as 1 byte + assert updated.length == byte_size(Frame.encode_vli(42)) + assert updated.type == 0x3 + end + + test "encodes a GOAWAY frame with stream_id: 42" do + frame = %GoAway{payload: %GoAway.Payload{stream_id: 42}} + assert {:ok, updated, iodata} = Frame.encode(frame) + bin = IO.iodata_to_binary(iodata) + assert <<0x07, _len, _stream_id_vli::binary>> = bin + assert updated.length == byte_size(Frame.encode_vli(42)) + assert updated.type == 0x7 + end + + test "encodes a MAX_PUSH_ID frame with push_id: 100" do + frame = %MaxPushId{payload: %MaxPushId.Payload{push_id: 100}} + assert {:ok, updated, iodata} = Frame.encode(frame) + bin = IO.iodata_to_binary(iodata) + assert <<0x0D, _len, _push_id_vli::binary>> = bin + # push_id 100 >= 64 so encodes as 2 bytes + assert updated.length == byte_size(Frame.encode_vli(100)) + assert updated.type == 0xD + end + + test "encodes a PUSH_PROMISE frame with push_id: 1, hbf: \"hdr\"" do + frame = %PushPromise{payload: %PushPromise.Payload{push_id: 1, hbf: "hdr"}} + assert {:ok, updated, iodata} = Frame.encode(frame) + bin = IO.iodata_to_binary(iodata) + # type 0x05, length VLI, then push_id=1 as 1-byte VLI (0x01), then hbf bytes + assert <<0x05, _len, 0x01, "hdr">> = bin + # payload = 1-byte push_id VLI + 3-byte hbf + assert updated.length == 4 + assert updated.type == 0x5 + end + + test "encodes an empty DATA frame" do + frame = %Data{payload: %Data.Payload{data: <<>>}} + assert {:ok, updated, iodata} = Frame.encode(frame) + assert IO.iodata_to_binary(iodata) == <<0x00, 0x00>> + assert updated.length == 0 + end + + test "sets updated_frame.length to the payload byte count" do + frame = %Data{payload: %Data.Payload{data: "hello world"}} + assert {:ok, updated, _iodata} = Frame.encode(frame) + assert updated.length == 11 + end + + test "preserves the integer type code in updated_frame" do + frame = %Headers{payload: %Headers.Payload{hbf: "x"}} + assert {:ok, updated, _iodata} = Frame.encode(frame) + assert updated.type == 0x1 + end + end + + # --------------------------------------------------------------------------- + # Streaming — Frame.stream/1 + # + # stream/1 uses Stream.unfold/2. After emitting {data, nil} for a partial + # frame the next accumulator state is `data` (unchanged), which would loop + # forever if the caller kept consuming. Therefore: + # • tests involving partial frames use Enum.take/2 to stop early. + # • tests where every byte belongs to a complete frame use Enum.to_list/1 + # (safe because the last next-state is <<>>, which terminates the stream). + # --------------------------------------------------------------------------- + + describe "stream/1" do + test "yields a complete DATA frame token" do + {:ok, _, iodata} = Frame.encode(%Data{payload: %Data.Payload{data: "hi"}}) + bin = IO.iodata_to_binary(iodata) + assert Frame.stream(bin) |> Enum.to_list() == [{"", {0, "hi"}}] + end + + test "yields a complete HEADERS frame token" do + {:ok, _, iodata} = Frame.encode(%Headers{payload: %Headers.Payload{hbf: "hdr"}}) + bin = IO.iodata_to_binary(iodata) + assert Frame.stream(bin) |> Enum.to_list() == [{"", {1, "hdr"}}] + end + + test "yields two consecutive frame tokens in order" do + {:ok, _, d_io} = Frame.encode(%Data{payload: %Data.Payload{data: "hi"}}) + {:ok, _, h_io} = Frame.encode(%Headers{payload: %Headers.Payload{hbf: "hdr"}}) + bin = IO.iodata_to_binary([d_io, h_io]) + tokens = Frame.stream(bin) |> Enum.map(fn {_rest, t} -> t end) + assert tokens == [{0, "hi"}, {1, "hdr"}] + end + + test "yields nothing for an empty binary" do + assert Frame.stream(<<>>) |> Enum.to_list() == [] + end + + test "yields {partial_bin, nil} for a partial frame" do + # A DATA frame that claims 5 bytes of payload but only has 2. + partial = <<0x00, 0x05, "ab">> + # Use Enum.take/2 — Enum.to_list/1 would loop because the unfold keeps + # returning the same partial buffer as the next accumulator state. + result = Frame.stream(partial) |> Enum.take(1) + assert [{^partial, nil}] = result + end + + test "the rest field of a complete-frame token equals the unconsumed bytes" do + {:ok, _, iodata} = Frame.encode(%Data{payload: %Data.Payload{data: "hi"}}) + leftover = "leftover" + bin = IO.iodata_to_binary(iodata) <> leftover + # Use Enum.take/2 because the leftover cannot be parsed as a valid frame + # and would cause the unfold to loop. + [{rest, {0, "hi"}}] = Frame.stream(bin) |> Enum.take(1) + assert rest == leftover + end + + test "carries the full payload for large frames (300 bytes)" do + payload = :binary.copy(<<0xAB>>, 300) + {:ok, _, iodata} = Frame.encode(%Data{payload: %Data.Payload{data: payload}}) + bin = IO.iodata_to_binary(iodata) + # No leftover — Enum.to_list/1 is safe. + [{_, {0, decoded_payload}}] = Frame.stream(bin) |> Enum.to_list() + assert decoded_payload == payload + end + + test "complete frame followed by partial yields two elements" do + {:ok, _, iodata} = Frame.encode(%Data{payload: %Data.Payload{data: "hi"}}) + # A HEADERS frame that claims 5 bytes but only has 2. + partial = <<0x01, 0x05, "ab">> + bin = IO.iodata_to_binary(iodata) <> partial + # Stop at 2 to avoid the infinite-loop behaviour on the partial tail. + result = Frame.stream(bin) |> Enum.take(2) + assert [{_, {0, "hi"}}, {^partial, nil}] = result + end + end + + # --------------------------------------------------------------------------- + # Frame decoding — struct-based API + # --------------------------------------------------------------------------- + + describe "decode/2" do + test "decodes a DATA payload" do + {:ok, decoded} = Frame.decode(%Data{}, "hello") + assert decoded.payload.data == "hello" + assert decoded.length == 5 + end + + test "decodes a HEADERS payload" do + {:ok, decoded} = Frame.decode(%Headers{}, "hbf") + assert decoded.payload.hbf == "hbf" + assert decoded.length == 3 + end + + test "decodes a SETTINGS payload with [{1, 0}, {7, 0}]" do + {:ok, decoded} = Frame.decode(%Settings{}, <<0x01, 0x00, 0x07, 0x00>>) + assert decoded.payload.settings == [{1, 0}, {7, 0}] + assert decoded.length == 4 + end + + test "decodes an empty SETTINGS payload" do + {:ok, decoded} = Frame.decode(%Settings{}, <<>>) + assert decoded.payload.settings == [] + assert decoded.length == 0 + end + + test "decodes a CANCEL_PUSH payload" do + payload_bin = Frame.encode_vli(42) + {:ok, decoded} = Frame.decode(%CancelPush{}, payload_bin) + assert decoded.payload.push_id == 42 + assert decoded.length == byte_size(payload_bin) + end + + test "decodes a GOAWAY payload" do + payload_bin = Frame.encode_vli(99) + {:ok, decoded} = Frame.decode(%GoAway{}, payload_bin) + assert decoded.payload.stream_id == 99 + assert decoded.length == byte_size(payload_bin) + end + + test "decodes a MAX_PUSH_ID payload" do + payload_bin = Frame.encode_vli(7) + {:ok, decoded} = Frame.decode(%MaxPushId{}, payload_bin) + assert decoded.payload.push_id == 7 + assert decoded.length == byte_size(payload_bin) + end + + test "decodes a PUSH_PROMISE payload" do + # push_id=1 encoded as a 1-byte VLI (0x01), followed by the HBF bytes. + payload = <<0x01, "hdr">> + {:ok, decoded} = Frame.decode(%PushPromise{}, payload) + assert decoded.payload.push_id == 1 + assert decoded.payload.hbf == "hdr" + assert decoded.length == byte_size(payload) + end + + test "sets decoded.length to byte_size(payload_binary) in every case" do + payload = "some arbitrary data" + {:ok, decoded} = Frame.decode(%Data{}, payload) + assert decoded.length == byte_size(payload) + end + end + + # --------------------------------------------------------------------------- + # Control-stream preface + # --------------------------------------------------------------------------- + + describe "control_stream_preface/0" do + test "is exactly 7 bytes long" do + assert byte_size(Frame.control_stream_preface()) == 7 + end + + test "starts with the control stream type byte 0x00" do + <> = Frame.control_stream_preface() + assert stream_type == 0x00 + end + + test "second byte is the SETTINGS frame type 0x04" do + <<_, frame_type, _::binary>> = Frame.control_stream_preface() + assert frame_type == 0x04 + end + + test "third byte is the payload length 4" do + <<_, _, length, _::binary>> = Frame.control_stream_preface() + assert length == 0x04 + end + + test "the embedded SETTINGS frame decodes with stream/1 and decode/2" do + <<_stream_type, rest::binary>> = Frame.control_stream_preface() + # rest is exactly one complete SETTINGS frame — Enum.to_list/1 is safe. + [{_, {type_int, payload_bin}}] = Frame.stream(rest) |> Enum.to_list() + assert type_int == 0x4 + {:ok, frame} = Frame.decode(%Settings{}, payload_bin) + assert frame.payload.settings == [{1, 0}, {7, 0}] + end + + test "payload decodes to QPACK_MAX_TABLE_CAPACITY=0 and QPACK_BLOCKED_STREAMS=0" do + <<_, _, _, payload::binary>> = Frame.control_stream_preface() + {:ok, frame} = Frame.decode(%Settings{}, payload) + assert frame.payload.settings == [{1, 0}, {7, 0}] + end + end + + # --------------------------------------------------------------------------- + # Encode / decode round-trips (struct → wire → struct) + # --------------------------------------------------------------------------- + + describe "encode/decode round-trips" do + test "round-trips a DATA frame" do + struct = %Data{payload: %Data.Payload{data: "hello"}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Data{}, payload_bin) + assert decoded.payload.data == "hello" + end + + test "round-trips a HEADERS frame" do + struct = %Headers{payload: %Headers.Payload{hbf: "header bytes"}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Headers{}, payload_bin) + assert decoded.payload.hbf == "header bytes" + end + + test "round-trips a SETTINGS frame" do + settings = [{1, 0}, {6, 4096}, {7, 0}] + struct = %Settings{payload: %Settings.Payload{settings: settings}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Settings{}, payload_bin) + assert decoded.payload.settings == settings + end + + test "round-trips a CANCEL_PUSH frame" do + struct = %CancelPush{payload: %CancelPush.Payload{push_id: 99}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%CancelPush{}, payload_bin) + assert decoded.payload.push_id == 99 + end + + test "round-trips a GOAWAY frame" do + struct = %GoAway{payload: %GoAway.Payload{stream_id: 7}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%GoAway{}, payload_bin) + assert decoded.payload.stream_id == 7 + end + + test "round-trips a MAX_PUSH_ID frame" do + struct = %MaxPushId{payload: %MaxPushId.Payload{push_id: 255}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%MaxPushId{}, payload_bin) + assert decoded.payload.push_id == 255 + end + + test "round-trips a PUSH_PROMISE frame" do + struct = %PushPromise{payload: %PushPromise.Payload{push_id: 3, hbf: "hbf bytes"}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%PushPromise{}, payload_bin) + assert decoded.payload.push_id == 3 + assert decoded.payload.hbf == "hbf bytes" + end + + test "round-trips a large DATA payload (70_000 bytes) — exercises multi-byte VLI length" do + payload = :binary.copy("x", 70_000) + struct = %Data{payload: %Data.Payload{data: payload}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Data{}, payload_bin) + assert decoded.payload.data == payload + end + + test "round-trips an empty DATA payload" do + struct = %Data{payload: %Data.Payload{data: <<>>}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Data{}, payload_bin) + assert decoded.payload.data == <<>> + end + + test "round-trips SETTINGS with multiple pairs [{1, 0}, {6, 4096}, {7, 0}]" do + settings = [{1, 0}, {6, 4096}, {7, 0}] + struct = %Settings{payload: %Settings.Payload{settings: settings}} + {:ok, _, iodata} = Frame.encode(struct) + + [{_, {type_int, payload_bin}}] = + Frame.stream(IO.iodata_to_binary(iodata)) |> Enum.to_list() + + assert type_int == struct.type + {:ok, decoded} = Frame.decode(%Settings{}, payload_bin) + assert decoded.payload.settings == settings + end + end +end diff --git a/test/ankh/protocol/http3/request_test.exs b/test/ankh/protocol/http3/request_test.exs new file mode 100644 index 0000000..3436165 --- /dev/null +++ b/test/ankh/protocol/http3/request_test.exs @@ -0,0 +1,411 @@ +defmodule Ankh.Protocol.HTTP3.RequestTest do + use ExUnit.Case, async: false + + @moduletag :integration + @moduletag timeout: 30_000 + + alias Ankh.HTTP + alias Ankh.HTTP.Request + + # ------------------------------------------------------------------------- + # Setup + # ------------------------------------------------------------------------- + + setup_all do + {:ok, _} = Application.ensure_all_started(:quicer) + :ok + end + + # ------------------------------------------------------------------------- + # Response helper + # ------------------------------------------------------------------------- + + # Drives the receive loop until end_stream = true is signalled for `ref`, + # returning {:ok, status, headers, body_binary} or {:error, reason}. + defp collect_response(protocol, ref, timeout \\ 10_000) do + do_collect(protocol, ref, timeout, nil, [], []) + end + + defp do_collect(protocol, ref, timeout, status, headers, body) do + receive do + msg -> + case HTTP.stream(protocol, msg) do + {:ok, new_proto, events} -> + case process_events(events, ref, status, headers, body) do + {:done, st, hd, bd} -> + {:ok, st, hd, IO.iodata_to_binary(Enum.reverse(bd))} + + {:cont, st, hd, bd} -> + do_collect(new_proto, ref, timeout, st, hd, bd) + end + + {:error, reason} -> + {:error, reason} + + _other -> + do_collect(protocol, ref, timeout, status, headers, body) + end + after + timeout -> {:error, :timeout} + end + end + + defp process_events([], _ref, status, headers, body), + do: {:cont, status, headers, body} + + defp process_events([event | rest], ref, status, headers, body) do + case event do + {:headers, ^ref, decoded_headers, end_stream} -> + new_status = + case List.keyfind(decoded_headers, ":status", 0) do + {":status", v} -> String.to_integer(v) + nil -> status + end + + regular = + Enum.reject(decoded_headers, fn {n, _} -> String.starts_with?(n, ":") end) + + new_headers = headers ++ regular + + if end_stream do + {:done, new_status, new_headers, body} + else + process_events(rest, ref, new_status, new_headers, body) + end + + {:data, ^ref, chunk, end_stream} -> + new_body = [chunk | body] + + if end_stream do + {:done, status, headers, new_body} + else + process_events(rest, ref, status, headers, new_body) + end + + {:error, ^ref, _reason, _complete} -> + {:done, status, headers, body} + + _other -> + process_events(rest, ref, status, headers, body) + end + end + + defp connect_h3!(uri_string, extra_opts \\ []) do + uri = URI.parse(uri_string) + opts = Keyword.merge([timeout: 10_000, protocols: [:h3]], extra_opts) + {:ok, protocol} = HTTP.connect(uri, opts) + protocol + end + + defp get!(protocol, path, extra_headers \\ []) do + request = + Request.new( + method: :GET, + path: path, + headers: [{"user-agent", "ankh-h3-test/1"}, {"accept", "*/*"}] ++ extra_headers + ) + + {:ok, protocol, ref} = HTTP.request(protocol, request) + {protocol, ref} + end + + # ------------------------------------------------------------------------- + # Basic GET requests + # ------------------------------------------------------------------------- + + describe "basic GET requests over HTTP/3" do + test "GET / from cloudflare-quic.com returns HTTP 200" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, status, headers, body} = collect_response(protocol, ref) + assert status == 200 + assert byte_size(body) > 0 + assert Enum.any?(headers, fn {k, _} -> k == "content-type" end) + end + + test "GET / response contains expected Cloudflare headers" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + header_names = Enum.map(headers, &elem(&1, 0)) + assert "server" in header_names + assert "date" in header_names + assert "content-type" in header_names + end + + test "GET / response server header identifies cloudflare" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + {_, server} = List.keyfind(headers, "server", 0) + assert server =~ "cloudflare" + end + + test "GET / advertises HTTP/3 support in Alt-Svc header" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + case List.keyfind(headers, "alt-svc", 0) do + {_, alt_svc} -> assert alt_svc =~ "h3" + nil -> flunk("expected Alt-Svc header advertising h3") + end + end + + test "GET / to cloudflare.com returns a redirect (3xx)" do + protocol = connect_h3!("https://cloudflare.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, status, _headers, _body} = collect_response(protocol, ref) + assert status in 200..399 + end + + test "GET / receives full body from cloudflare-quic.com" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, body} = collect_response(protocol, ref) + + # Verify content-length matches received body when present + case List.keyfind(headers, "content-length", 0) do + {_, cl_str} -> + content_length = String.to_integer(cl_str) + assert byte_size(body) == content_length + + nil -> + assert byte_size(body) > 0 + end + end + + test "GET / response body is valid HTML" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, _headers, body} = collect_response(protocol, ref) + assert body =~ "" or body =~ " k == "cf-ray" end) + end + + test "Accept header is sent" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/", [{"accept", "text/html"}]) + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + {_, content_type} = List.keyfind(headers, "content-type", 0) + assert content_type =~ "text/html" + end + + test "multiple custom headers are sent successfully" do + protocol = connect_h3!("https://cloudflare-quic.com") + + extra = [ + {"accept-language", "en-US,en;q=0.9"}, + {"cache-control", "no-cache"} + ] + + {protocol, ref} = get!(protocol, "/", extra) + + # Request should succeed — the key assertion is no error + assert {:ok, status, _headers, _body} = collect_response(protocol, ref) + assert status in 200..399 + end + end + + # ------------------------------------------------------------------------- + # Protocol negotiation + # ------------------------------------------------------------------------- + + describe "protocol negotiation" do + test "protocols: [:h3] connects with HTTP/3" do + # The connection itself succeeding over QUIC is the assertion. + # We also verify the server sends its three control/QPACK streams + # (visible via debug logs) but just assert the response works. + protocol = connect_h3!("https://cloudflare-quic.com", protocols: [:h3]) + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, _headers, body} = collect_response(protocol, ref) + assert byte_size(body) > 0 + end + + test "protocols: [:h3, :h2, :\"http/1.1\"] prefers HTTP/3 when available" do + uri = URI.parse("https://cloudflare-quic.com") + opts = [timeout: 10_000, protocols: [:h3, :h2, :"http/1.1"]] + assert {:ok, protocol} = HTTP.connect(uri, opts) + + {protocol, ref} = get!(protocol, "/") + assert {:ok, 200, _headers, body} = collect_response(protocol, ref) + assert byte_size(body) > 0 + end + + test "default https:// connection uses TLS (no :protocols option)" do + uri = URI.parse("https://cloudflare.com") + + # Default: TLS, ALPN h2 / http1.1 — no protocols opt means TLS path + assert {:ok, protocol} = HTTP.connect(uri, timeout: 10_000) + + {protocol, ref} = get!(protocol, "/") + assert {:ok, status, _headers, _body} = collect_response(protocol, ref) + assert status in 200..399 + end + + test "protocols: [:h3] against one.one.one.one succeeds" do + uri = URI.parse("https://1.1.1.1") + opts = [timeout: 10_000, protocols: [:h3], verify: :none] + assert {:ok, protocol} = HTTP.connect(uri, opts) + + {protocol, ref} = get!(protocol, "/") + assert {:ok, status, _headers, _body} = collect_response(protocol, ref) + assert status in 200..399 + end + end + + # ------------------------------------------------------------------------- + # Multiple sequential requests on the same connection + # ------------------------------------------------------------------------- + + describe "multiple sequential requests" do + test "two sequential GET requests on the same H3 connection both succeed" do + protocol = connect_h3!("https://cloudflare-quic.com") + + # First request + {protocol, ref1} = get!(protocol, "/") + assert {:ok, 200, _headers1, body1} = collect_response(protocol, ref1) + assert byte_size(body1) > 0 + + # Second request on the same connection + {protocol, ref2} = get!(protocol, "/") + assert {:ok, 200, _headers2, body2} = collect_response(protocol, ref2) + assert byte_size(body2) > 0 + end + + test "three sequential requests all return 200" do + protocol = connect_h3!("https://cloudflare-quic.com") + + for _ <- 1..3 do + {protocol, ref} = get!(protocol, "/") + assert {:ok, 200, _headers, body} = collect_response(protocol, ref) + assert byte_size(body) > 0 + {protocol, nil} + end + end + + test "response bodies are consistent across sequential requests" do + protocol = connect_h3!("https://cloudflare-quic.com") + + {protocol, ref1} = get!(protocol, "/") + {:ok, 200, _h1, body1} = collect_response(protocol, ref1) + + {protocol2, ref2} = get!(protocol, "/") + {:ok, 200, _h2, body2} = collect_response(protocol2, ref2) + + # Both fetches of the same static page should return the same byte count + assert byte_size(body1) == byte_size(body2) + end + end + + # ------------------------------------------------------------------------- + # QPACK header decoding correctness + # ------------------------------------------------------------------------- + + describe "QPACK header decoding" do + test "all response header names are lowercase strings" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + for {name, _value} <- headers do + assert name == String.downcase(name), + "header name #{inspect(name)} is not lowercase" + end + end + + test "response headers do not contain HTTP/2-style pseudo-headers" do + # Pseudo-headers (:status, :path, etc.) must be stripped by the protocol + # layer before returning responses to the caller. + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, _status, headers, _body} = collect_response(protocol, ref) + + pseudo_headers = + Enum.filter(headers, fn {name, _} -> String.starts_with?(name, ":") end) + + assert pseudo_headers == [], + "expected no pseudo-headers in decoded response, got: #{inspect(pseudo_headers)}" + end + + test "date header is a valid HTTP date string" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + assert {_, date_value} = List.keyfind(headers, "date", 0) + # HTTP date contains a day name and a year + assert date_value =~ ~r/(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ + assert date_value =~ ~r/20\d\d/ + end + + test "content-length header value parses as a non-negative integer" do + protocol = connect_h3!("https://cloudflare-quic.com") + {protocol, ref} = get!(protocol, "/") + + assert {:ok, 200, headers, _body} = collect_response(protocol, ref) + + case List.keyfind(headers, "content-length", 0) do + {_, value} -> + {length, ""} = Integer.parse(value) + assert length >= 0 + + nil -> + :ok + end + end + end + + # ------------------------------------------------------------------------- + # Error handling + # ------------------------------------------------------------------------- + + describe "connection error handling" do + test "connecting to an unreachable host returns an error" do + uri = URI.parse("https://this.host.does.not.exist.invalid") + result = HTTP.connect(uri, timeout: 3_000, protocols: [:h3]) + assert {:error, _reason} = result + end + + test "protocols: [:h3] to a TLS-only host fails gracefully (no fallback)" do + # A host that doesn't speak QUIC on 443 should return an error when only + # :h3 is requested (no TLS fallback). + uri = URI.parse("https://example.com") + result = HTTP.connect(uri, timeout: 5_000, protocols: [:h3]) + + # We accept either :error (QUIC refused/timeout) or a successful H3 + # connection (example.com may or may not support H3). + # The key assertion: no crash, just a result tuple. + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end +end From 93aeaffb99b72071a0c7a91e75373d3ffc435f69 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Fri, 27 Mar 2026 19:58:37 +0100 Subject: [PATCH 02/13] Small fixes --- lib/ankh/http.ex | 1 - lib/ankh/protocol/http3.ex | 7 ++----- lib/ankh/transport/quic.ex | 36 ++++++++++++------------------------ 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/lib/ankh/http.ex b/lib/ankh/http.ex index cf2b206..821a6b4 100644 --- a/lib/ankh/http.ex +++ b/lib/ankh/http.ex @@ -150,7 +150,6 @@ defmodule Ankh.HTTP do # clauses ({:error, _}) always see a consistent shape. case Transport.connect(%QUIC{}, uri, timeout, quic_options) do {:ok, transport} -> Protocol.connect(%HTTP3{}, uri, transport, options) - {:error, reason, _props} -> {:error, reason} {:error, _} = error -> error end end diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index 0b4d53b..dbed09f 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -187,9 +187,7 @@ defmodule Ankh.Protocol.HTTP3 do @doc """ Closes the QUIC connection, signalling a connection-level error to the peer. """ - def error(%@for{transport: transport}) do - QUIC.close_connection(transport) - end + def error(%@for{transport: transport}), do: Transport.close(transport) # ------------------------------------------------------------------------- # request/2 @@ -633,8 +631,7 @@ defmodule Ankh.Protocol.HTTP3 do "HTTP/3 new stream: handle=#{inspect(stream_handle)}, unidirectional=#{unidirectional?}" ) - protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} - {:ok, protocol, []} + {:ok, %@for{protocol | streams: Map.put(streams, stream_handle, stream)}, []} end end end diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index 801c649..ae9c73c 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -134,20 +134,6 @@ defmodule Ankh.Transport.QUIC do end end - @doc """ - Closes the QUIC **connection** without first closing a specific stream. - - Used by `Ankh.Protocol.HTTP3.error/1` to tear down the whole connection - when a connection-level error is detected. - """ - @spec close_connection(t()) :: :ok | {:error, any()} - def close_connection(%__MODULE__{connection: conn}) when not is_nil(conn) do - case :quicer.close_connection(conn) do - :ok -> :ok - {:error, _} = error -> error - end - end - defimpl Transport do # Default ALPN for HTTP/3. @default_alpn ["h3"] @@ -227,8 +213,15 @@ defmodule Ankh.Transport.QUIC do ) |> Map.new() - with {:ok, conn} <- :quicer.connect(hostname, port || 443, conn_opts, timeout) do - {:ok, %{transport | connection: conn}} + case :quicer.connect(hostname, port || 443, conn_opts, timeout) do + {:ok, conn} -> + {:ok, %{transport | connection: conn}} + + {:error, :transport_down, _props} -> + {:error, :closed} + + {:error, reason} -> + {:error, reason} end end @@ -426,20 +419,15 @@ defmodule Ankh.Transport.QUIC do Delegates to `:quicer.negotiated_protocol/1` on the connection handle. Returns `{:error, :protocol_not_negotiated}` if no connection is present. """ - # quicer's negotiated_protocol/1 delegates to quicer_nif:getopt/3, a NIF - # whose Dialyzer success-typing resolves to only {:error, atom()}. The - # quicer @spec correctly declares {:ok, binary()} | {:error, any()}, but - # Dialyzer's conservative NIF analysis cannot see the {:ok, _} branch as - # reachable. This is a confirmed false positive; suppress it here rather - # than in the ignore-warnings file because Erlex fails to parse the raw - # Erlang type in the warning args, causing dialyxir's filter to silently - # skip the entry. @dialyzer {:nowarn_function, [negotiated_protocol: 1]} def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn) do case :quicer.negotiated_protocol(conn) do {:ok, protocol} -> {:ok, protocol} + {:error, _reason, _props} -> + {:error, :protocol_not_negotiated} + {:error, _reason} -> {:error, :protocol_not_negotiated} end From acaaadb41938a14195ddda5345f9de9d8efd77de Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Fri, 27 Mar 2026 20:52:18 +0100 Subject: [PATCH 03/13] Small fixes --- lib/ankh/protocol/http3.ex | 122 ++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 71 deletions(-) diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index dbed09f..ca38272 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -3,8 +3,8 @@ defmodule Ankh.Protocol.HTTP3 do HTTP/3 protocol implementation for Ankh. This module implements the `Ankh.Protocol` protocol over a QUIC connection - managed by the `quicer` library. It supports both the client (`connect/4`) and - server (`accept/5`) sides of an HTTP/3 connection. + managed by the `Ankh.Transport.QUIC` transport. It supports both the client + (`connect/4`) and server (`accept/5`) sides of an HTTP/3 connection. ## Architecture @@ -14,7 +14,7 @@ defmodule Ankh.Protocol.HTTP3 do forwarded directly to this protocol's `stream/2` callback rather than being unwrapped by the transport layer. - Each QUIC stream is tracked in the `streams` map keyed by the opaque quicer + Each QUIC stream is tracked in the `streams` map keyed by the opaque QUIC stream handle. A parallel `references` map provides the reverse lookup from Elixir request references to stream handles, allowing callers to correlate `stream/2` response events back to the originating `request/2` or `respond/3` @@ -133,7 +133,7 @@ defmodule Ankh.Protocol.HTTP3 do the mandatory SETTINGS preface (RFC 9114 §6.2.1) before returning. The `socket` argument must be a raw QUIC connection handle (`reference()`) - as returned by quicer after accepting a connection from a listener. + as returned by the transport after accepting a connection from a listener. """ def accept(%@for{} = protocol, uri, transport, socket, _options) do with {:ok, %QUIC{connection: conn} = transport} <- Transport.new(transport, socket), @@ -361,15 +361,52 @@ defmodule Ankh.Protocol.HTTP3 do end def stream(%@for{} = protocol, {:quic, :peer_send_shutdown, stream_handle, _props}) do - handle_peer_send_shutdown(protocol, stream_handle) + case Map.get(protocol.streams, stream_handle) do + nil -> + {:ok, protocol, []} + + stream -> + stream = Stream.remote_fin(stream) + + { + :ok, + %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, + [{:data, stream.reference, <<>>, true}] + } + end end def stream(%@for{} = protocol, {:quic, :stream_closed, stream_handle, _props}) do - handle_stream_closed(protocol, stream_handle) + case Map.get(protocol.streams, stream_handle) do + nil -> + {:ok, protocol, []} + + stream -> + stream = + stream + |> Stream.remote_fin() + |> Stream.local_fin() + + { + :ok, + %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, + [] + } + end end def stream(%@for{} = protocol, {:quic, :new_stream, stream_handle, props}) do - handle_new_stream(protocol, stream_handle, props) + unidirectional? = Map.get(props, :flags, 0) == 1 + stream = Stream.new_incoming(stream_handle, unidirectional?) + + %QUIC{} = quic_transport = protocol.transport + QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) + + Logger.debug( + "HTTP/3 new stream: handle=#{inspect(stream_handle)}, unidirectional=#{unidirectional?}" + ) + + {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, []} end def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}) do @@ -388,7 +425,7 @@ defmodule Ankh.Protocol.HTTP3 do # Private helpers # ========================================================================= - # Resolves an Elixir request reference to its quicer stream handle and + # Resolves an Elixir request reference to its QUIC stream handle and # per-stream state struct. Returns `{:error, :unknown_reference}` when # the reference is not present in the protocol maps. defp lookup_stream(%@for{references: references, streams: streams}, ref) do @@ -529,9 +566,8 @@ defmodule Ankh.Protocol.HTTP3 do stream, _stream_handle, %Data{payload: %Data.Payload{data: data}} - ) do - {:ok, protocol, stream, [{:data, stream.reference, data, false}]} - end + ), + do: {:ok, protocol, stream, [{:data, stream.reference, data, false}]} # SETTINGS frame — update `remote_settings` and log; no response event. defp recv_frame( @@ -545,15 +581,13 @@ defmodule Ankh.Protocol.HTTP3 do end # GOAWAY frame — emit a connection-level error event (RFC 9114 §7.2.6). - defp recv_frame(%@for{} = protocol, stream, _stream_handle, %GoAway{}) do - {:ok, protocol, stream, [{:error, stream.reference, :goaway, true}]} - end + defp recv_frame(%@for{} = protocol, stream, _stream_handle, %GoAway{}), + do: {:ok, protocol, stream, [{:error, stream.reference, :goaway, true}]} # All other known and unknown frame types are silently ignored per # RFC 9114 §9 ("extension frames MUST be ignored"). - defp recv_frame(%@for{} = protocol, stream, _stream_handle, _frame) do - {:ok, protocol, stream, []} - end + defp recv_frame(%@for{} = protocol, stream, _stream_handle, _frame), + do: {:ok, protocol, stream, []} # Validates pseudo-headers on the first HEADERS block of a stream. # @@ -579,59 +613,5 @@ defmodule Ankh.Protocol.HTTP3 do end defp validate_incoming_headers(_protocol, _stream, _headers), do: :ok - - # Handles a `:peer_send_shutdown` (remote FIN) event. - # - # Transitions the stream to `:half_closed_remote` (or `:closed` if the local - # side has already sent a FIN) and emits an empty DATA event with - # `complete = true` to signal end-of-stream to the caller. - defp handle_peer_send_shutdown(%@for{streams: streams} = protocol, stream_handle) do - case Map.get(streams, stream_handle) do - nil -> - {:ok, protocol, []} - - stream -> - stream = Stream.remote_fin(stream) - protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} - {:ok, protocol, [{:data, stream.reference, <<>>, true}]} - end - end - - # Handles a `:stream_closed` event: both transport sides are finished. - # Applies `remote_fin` and `local_fin` to drive the stream to `:closed`. - defp handle_stream_closed(%@for{streams: streams} = protocol, stream_handle) do - case Map.get(streams, stream_handle) do - nil -> - {:ok, protocol, []} - - stream -> - stream = - stream - |> Stream.remote_fin() - |> Stream.local_fin() - - protocol = %@for{protocol | streams: Map.put(streams, stream_handle, stream)} - {:ok, protocol, []} - end - end - - # Handles a `:new_stream` notification from quicer (server-side). - # - # Creates the per-stream state (bidirectional for request streams, - # unidirectional for control/QPACK/push streams), arms active delivery - # for the new stream, and returns with no immediate response events. - defp handle_new_stream(%@for{streams: streams} = protocol, stream_handle, props) do - unidirectional? = Map.get(props, :flags, 0) == 1 - stream = Stream.new_incoming(stream_handle, unidirectional?) - - %QUIC{} = quic_transport = protocol.transport - QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) - - Logger.debug( - "HTTP/3 new stream: handle=#{inspect(stream_handle)}, unidirectional=#{unidirectional?}" - ) - - {:ok, %@for{protocol | streams: Map.put(streams, stream_handle, stream)}, []} - end end end From f79312f88d0c136f68e491d42079c092aefe93a8 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Fri, 27 Mar 2026 21:00:20 +0100 Subject: [PATCH 04/13] Cleanup --- lib/ankh/protocol/http3.ex | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index ca38272..de3a70f 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -409,17 +409,11 @@ defmodule Ankh.Protocol.HTTP3 do {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, []} end - def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}) do - {:error, :closed} - end + def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}), do: {:error, :closed} - def stream(%@for{} = _protocol, {:quic, :closed, _conn, _props}) do - {:error, :closed} - end + def stream(%@for{} = _protocol, {:quic, :closed, _conn, _props}), do: {:error, :closed} - def stream(%@for{} = protocol, _msg) do - {:ok, protocol, []} - end + def stream(%@for{} = protocol, _msg), do: {:ok, protocol, []} # ========================================================================= # Private helpers From 74e47bb32d141f7006e82b46d2fef80a08fc5033 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Fri, 27 Mar 2026 21:06:17 +0100 Subject: [PATCH 05/13] Cleanup --- lib/ankh/protocol/http3.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index de3a70f..0e61c92 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -409,7 +409,8 @@ defmodule Ankh.Protocol.HTTP3 do {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, []} end - def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}), do: {:error, :closed} + def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}), + do: {:error, :closed} def stream(%@for{} = _protocol, {:quic, :closed, _conn, _props}), do: {:error, :closed} From b613a7c7d3d7d994c3e2378cdd3b9747b9be6e7c Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 21 May 2026 11:02:01 +0200 Subject: [PATCH 06/13] Replace quicer with erlang_quic --- lib/ankh/http.ex | 30 +- lib/ankh/protocol/http3.ex | 213 +++--- lib/ankh/protocol/http3/qpack.ex | 540 --------------- lib/ankh/protocol/http3/qpack/huffman.ex | 634 ------------------ lib/ankh/protocol/http3/qpack/static_table.ex | 227 ------- lib/ankh/protocol/http3/qpack/table.ex | 272 -------- lib/ankh/protocol/http3/stream.ex | 18 +- lib/ankh/transport/quic.ex | 372 +++++----- mix.exs | 2 +- mix.lock | 2 - test/ankh/protocol/http3/request_test.exs | 9 - 11 files changed, 306 insertions(+), 2013 deletions(-) delete mode 100644 lib/ankh/protocol/http3/qpack.ex delete mode 100644 lib/ankh/protocol/http3/qpack/huffman.ex delete mode 100644 lib/ankh/protocol/http3/qpack/static_table.ex delete mode 100644 lib/ankh/protocol/http3/qpack/table.ex diff --git a/lib/ankh/http.ex b/lib/ankh/http.ex index 821a6b4..042fb7b 100644 --- a/lib/ankh/http.ex +++ b/lib/ankh/http.ex @@ -79,12 +79,15 @@ defmodule Ankh.HTTP do end end - def accept(%URI{scheme: "https"} = uri, socket, options) when is_reference(socket) do - # QUIC connection — check negotiated ALPN and dispatch to HTTP/3. - with {:ok, negotiated_protocol} <- - Transport.negotiated_protocol(%QUIC{connection: socket}), + def accept(%URI{scheme: "https"} = uri, socket, options) when is_pid(socket) do + # erlang_quic (pure Erlang) connection — pid carries the negotiated ALPN. + # The caller may pass alpn: binary() in options; default to "h3" for QUIC. + alpn = Keyword.get(options, :alpn, "h3") + transport = %QUIC{alpn: alpn} + + with {:ok, negotiated_protocol} <- Transport.negotiated_protocol(transport), {:ok, protocol} <- protocol_for_id(negotiated_protocol) do - Protocol.accept(protocol, uri, %QUIC{}, socket, options) + Protocol.accept(protocol, uri, transport, socket, options) end end @@ -131,23 +134,18 @@ defmodule Ankh.HTTP do def connect(_uri, _options), do: {:error, :unsupported_uri_scheme} - # Establishes an HTTP/3 connection over QUIC. + # Establishes an HTTP/3 connection over QUIC using erlang_quic. # - # quicer's alpn() type is an Erlang string() (charlist), not a binary. - # peer_unidi_stream_count: 3 lets the server open the three unidirectional + # erlang_quic takes ALPN as Elixir binaries (not charlists). + # max_streams_uni: 3 allows the server to open the three unidirectional # streams required by HTTP/3 (control, QPACK encoder, QPACK decoder). defp connect_quic(%URI{} = uri, options) do {timeout, options} = Keyword.pop(options, :timeout, 5_000) quic_options = - [alpn: [~c"h3"], verify: :verify_peer, peer_unidi_stream_count: 3] - |> Keyword.merge( - Keyword.take(options, [:cacertfile, :certfile, :keyfile, :password, :verify]) - ) - - # quicer returns a 3-tuple for transport-level failures (e.g. DNS errors, - # TLS handshake failures). Normalise to a 2-tuple so the caller's case - # clauses ({:error, _}) always see a consistent shape. + [alpn: ["h3"], verify: true, max_streams_uni: 3] + |> Keyword.merge(Keyword.take(options, [:verify, :max_streams_uni])) + case Transport.connect(%QUIC{}, uri, timeout, quic_options) do {:ok, transport} -> Protocol.connect(%HTTP3{}, uri, transport, options) {:error, _} = error -> error diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index 0e61c92..687c45b 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -51,16 +51,16 @@ defmodule Ankh.Protocol.HTTP3 do ## Incoming message dispatch - `stream/2` pattern-matches the raw QUIC tuple messages delivered by quicer: - - * `{:quic, data, handle, _}` — binary data; appended to the stream buffer - and parsed as HTTP/3 frames. - * `{:quic, :peer_send_shutdown, handle, _}` — remote FIN; signals - end-of-stream to the caller via an empty DATA event with `complete = true`. - * `{:quic, :stream_closed, handle, _}` — stream fully closed by both sides. - * `{:quic, :new_stream, handle, props}` — new inbound stream from the peer - (server-side). - * `{:quic, :transport_shutdown | :closed, _, _}` — connection teardown; + `stream/2` pattern-matches the raw QUIC tuple messages delivered by erlang_quic: + + * `{:quic, conn, {:stream_data, id, data, fin}}` — binary data (and optional + FIN); appended to the stream buffer and parsed as HTTP/3 frames. When + `fin = true` an empty DATA event with `complete = true` is also emitted. + * `{:quic, conn, {:stream_opened, id}}` — new inbound stream from the peer + (server-side). Stream directionality is derived from the stream ID. + * `{:quic, conn, {:stream_reset, id, code}}` — stream reset by the peer; + both half-closes are recorded. + * `{:quic, conn, {:transport_error | :closed, …}}` — connection teardown; returns `{:error, :closed}`. ## References @@ -71,7 +71,7 @@ defmodule Ankh.Protocol.HTTP3 do """ alias Ankh.{Protocol, Transport} - alias Ankh.Protocol.HTTP3.{QPACK, Stream} + alias Ankh.Protocol.HTTP3.Stream alias Ankh.Transport.QUIC @typedoc """ @@ -83,8 +83,8 @@ defmodule Ankh.Protocol.HTTP3 do """ @opaque t :: %__MODULE__{ mode: :client | :server | nil, - recv_qpack: QPACK.t() | nil, - send_qpack: QPACK.t() | nil, + recv_qpack: :quic_qpack.state() | nil, + send_qpack: :quic_qpack.state() | nil, streams: %{optional(reference()) => Stream.t()}, references: %{optional(reference()) => reference()}, transport: QUIC.t() | nil, @@ -105,7 +105,7 @@ defmodule Ankh.Protocol.HTTP3 do defimpl Protocol do alias Ankh.HTTP.{Request, Response} - alias Ankh.Protocol.HTTP3.{Frame, QPACK, Stream} + alias Ankh.Protocol.HTTP3.{Frame, Stream} alias Ankh.Protocol.HTTP3.Frame.{ CancelPush, @@ -146,8 +146,8 @@ defmodule Ankh.Protocol.HTTP3 do | mode: :server, transport: transport, uri: uri, - recv_qpack: QPACK.new(), - send_qpack: QPACK.new() + recv_qpack: :quic_qpack.new(), + send_qpack: :quic_qpack.new() }} end end @@ -174,8 +174,8 @@ defmodule Ankh.Protocol.HTTP3 do | mode: :client, transport: transport, uri: uri, - recv_qpack: QPACK.new(), - send_qpack: QPACK.new() + recv_qpack: :quic_qpack.new(), + send_qpack: :quic_qpack.new() }} end end @@ -220,16 +220,12 @@ defmodule Ankh.Protocol.HTTP3 do ] all_headers = pseudo_headers ++ req_headers - {hbf, send_qpack} = QPACK.encode(:store, all_headers, protocol.send_qpack) + {hbf, send_qpack} = :quic_qpack.encode(all_headers, protocol.send_qpack) - headers_bin = IO.iodata_to_binary(hbf) - - {:ok, _, headers_frame_data} = - Frame.encode(%Headers{payload: %Headers.Payload{hbf: headers_bin}}) - - headers_frame_bin = IO.iodata_to_binary(headers_frame_data) - - with {:ok, stream_transport} <- QUIC.open_stream(transport), + with {:ok, _, headers_frame_data} <- + Frame.encode(%Headers{payload: %Headers.Payload{hbf: IO.iodata_to_binary(hbf)}}), + headers_frame_bin = IO.iodata_to_binary(headers_frame_data), + {:ok, stream_transport} <- QUIC.open_stream(transport), :ok <- Transport.send(stream_transport, headers_frame_bin) do if IO.iodata_length(body) > 0 do body_bin = IO.iodata_to_binary(body) @@ -282,15 +278,12 @@ defmodule Ankh.Protocol.HTTP3 do %Response{status: status, headers: resp_headers, body: body, trailers: trailers} ) do with {:ok, handle, stream} <- lookup_stream(protocol, request_reference) do - pseudo_headers = [{":status", Integer.to_string(status)}] - all_headers = pseudo_headers ++ resp_headers + headers = [{":status", Integer.to_string(status)} | resp_headers] - {hbf, send_qpack} = QPACK.encode(:store, all_headers, protocol.send_qpack) - - headers_bin = IO.iodata_to_binary(hbf) + {hbf, send_qpack} = :quic_qpack.encode(headers, protocol.send_qpack) {:ok, _, headers_frame_data} = - Frame.encode(%Headers{payload: %Headers.Payload{hbf: headers_bin}}) + Frame.encode(%Headers{payload: %Headers.Payload{hbf: IO.iodata_to_binary(hbf)}}) headers_frame_bin = IO.iodata_to_binary(headers_frame_data) %QUIC{} = quic_transport = protocol.transport @@ -309,7 +302,7 @@ defmodule Ankh.Protocol.HTTP3 do send_qpack = if trailers != [] do - {trailer_hbf, sq} = QPACK.encode(:store, trailers, send_qpack) + {trailer_hbf, sq} = :quic_qpack.encode(trailers, send_qpack) trailer_bin = IO.iodata_to_binary(trailer_hbf) {:ok, _, trailer_frame_data} = @@ -355,64 +348,38 @@ defmodule Ankh.Protocol.HTTP3 do * `{:data, ref, data, complete?}` — a DATA frame or end-of-stream signal * `{:error, ref, reason, true}` — a stream-level protocol error """ - def stream(%@for{} = protocol, {:quic, data, stream_handle, _props}) + # ------------------------------------------------------------------------- + # stream/2 — incoming erlang_quic message dispatcher + # ------------------------------------------------------------------------- + + # Data on a stream (no FIN). + def stream(%@for{} = protocol, {:quic, _conn, {:stream_data, stream_id, data, false}}) when is_binary(data) do - handle_stream_data(protocol, stream_handle, data) + handle_stream_data(protocol, stream_id, data) end - def stream(%@for{} = protocol, {:quic, :peer_send_shutdown, stream_handle, _props}) do - case Map.get(protocol.streams, stream_handle) do - nil -> - {:ok, protocol, []} - - stream -> - stream = Stream.remote_fin(stream) - - { - :ok, - %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, - [{:data, stream.reference, <<>>, true}] - } + # Data WITH FIN — process frames then signal end of stream. + def stream(%@for{} = protocol, {:quic, _conn, {:stream_data, stream_id, data, true}}) do + with {:ok, protocol, responses} <- handle_stream_data(protocol, stream_id, data), + {:ok, protocol, fin_responses} <- handle_peer_send_shutdown(protocol, stream_id) do + {:ok, protocol, responses ++ fin_responses} end end - def stream(%@for{} = protocol, {:quic, :stream_closed, stream_handle, _props}) do - case Map.get(protocol.streams, stream_handle) do - nil -> - {:ok, protocol, []} - - stream -> - stream = - stream - |> Stream.remote_fin() - |> Stream.local_fin() - - { - :ok, - %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, - [] - } - end + # New stream opened by peer (server-side). + def stream(%@for{} = protocol, {:quic, _conn, {:stream_opened, stream_id}}) do + handle_new_stream(protocol, stream_id) end - def stream(%@for{} = protocol, {:quic, :new_stream, stream_handle, props}) do - unidirectional? = Map.get(props, :flags, 0) == 1 - stream = Stream.new_incoming(stream_handle, unidirectional?) - - %QUIC{} = quic_transport = protocol.transport - QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) - - Logger.debug( - "HTTP/3 new stream: handle=#{inspect(stream_handle)}, unidirectional=#{unidirectional?}" - ) - - {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, []} + # Peer reset the stream. + def stream(%@for{} = protocol, {:quic, _conn, {:stream_reset, stream_id, _error_code}}) do + handle_stream_closed(protocol, stream_id) end - def stream(%@for{} = _protocol, {:quic, :transport_shutdown, _conn, _props}), + def stream(%@for{} = _protocol, {:quic, _conn, {:transport_error, _code, _reason}}), do: {:error, :closed} - def stream(%@for{} = _protocol, {:quic, :closed, _conn, _props}), do: {:error, :closed} + def stream(%@for{} = _protocol, {:quic, _conn, {:closed, _reason}}), do: {:error, :closed} def stream(%@for{} = protocol, _msg), do: {:ok, protocol, []} @@ -436,10 +403,11 @@ defmodule Ankh.Protocol.HTTP3 do # # Retrieves (or lazily creates) the per-stream state, optionally strips the # unidirectional stream-type byte (RFC 9114 §6.2 / RFC 9204 §4.2) from - # newly-seen streams, appends the data to the stream buffer, runs the HTTP/3 - # frame parser, and re-arms active delivery. - defp handle_stream_data(%@for{streams: streams} = protocol, stream_handle, data) do - stream = Map.get(streams, stream_handle, Stream.new_incoming(stream_handle, false)) + # newly-seen streams, appends the data to the stream buffer, and runs the + # HTTP/3 frame parser. No re-arm is needed — erlang_quic delivers messages + # automatically. + defp handle_stream_data(%@for{streams: streams} = protocol, stream_id, data) do + stream = Map.get(streams, stream_id, Stream.new_incoming(stream_id, false)) # For unidirectional streams whose kind has not yet been identified, # the very first byte of data is the stream-type byte. Strip it, resolve @@ -448,13 +416,57 @@ defmodule Ankh.Protocol.HTTP3 do stream = Stream.append(stream, effective_data) - with {:ok, protocol, responses} <- process_stream_frames(protocol, stream_handle, stream) do - %QUIC{} = quic_transport = protocol.transport - QUIC.rearm_stream(%QUIC{quic_transport | stream: stream_handle}) - {:ok, protocol, responses} + process_stream_frames(protocol, stream_id, stream) + end + + # Signals end-of-stream to the caller via an empty DATA event with + # `complete = true`. Called when the peer sends FIN (fin=true on the last + # :stream_data, or implicitly after a :stream_reset). + defp handle_peer_send_shutdown(%@for{streams: streams} = protocol, stream_id) do + case Map.get(streams, stream_id) do + nil -> + {:ok, protocol, []} + + stream -> + stream = Stream.remote_fin(stream) + + { + :ok, + %@for{protocol | streams: Map.put(protocol.streams, stream_id, stream)}, + [{:data, stream.reference, <<>>, true}] + } end end + # Marks both halves of the stream as closed. Called when the peer resets + # the stream via a RESET_STREAM frame. + defp handle_stream_closed(%@for{streams: streams} = protocol, stream_id) do + case Map.get(streams, stream_id) do + nil -> + {:ok, protocol, []} + + stream -> + stream = + stream + |> Stream.remote_fin() + |> Stream.local_fin() + + {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_id, stream)}, []} + end + end + + # Registers a newly opened inbound stream. Unidirectionality is inferred + # from bit 1 of the QUIC stream ID (RFC 9000 §2.1): if set, the stream is + # unidirectional. + defp handle_new_stream(%@for{} = protocol, stream_id) do + unidirectional? = Bitwise.band(stream_id, 2) != 0 + stream = Stream.new_incoming(stream_id, unidirectional?) + + Logger.debug("HTTP/3 new stream: id=#{stream_id}, unidirectional=#{unidirectional?}") + + {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_id, stream)}, []} + end + # Strips the stream-type byte from an unidirectional stream whose kind is not # yet known and updates the stream kind via `Stream.identify_kind/2`. # All other stream kinds pass data through unchanged. @@ -532,14 +544,14 @@ defmodule Ankh.Protocol.HTTP3 do # HEADERS frame — QPACK-decode the HBF, validate pseudo-headers, emit # a `:headers` response event. defp recv_frame( - %@for{recv_qpack: recv_qpack} = protocol, + %@for{} = protocol, stream, _stream_handle, %Headers{payload: %Headers.Payload{hbf: hbf}} ) do - case QPACK.decode(hbf, recv_qpack) do - {:ok, headers, recv_qpack} -> - protocol = %@for{protocol | recv_qpack: recv_qpack} + case :quic_qpack.decode(hbf, protocol.recv_qpack) do + {{:ok, headers}, recv_qpack} -> + protocol = %{protocol | recv_qpack: recv_qpack} case validate_incoming_headers(protocol, stream, headers) do :ok -> @@ -550,8 +562,21 @@ defmodule Ankh.Protocol.HTTP3 do {:ok, protocol, stream, [{:error, stream.reference, reason, true}]} end - {:error, reason} -> - {:ok, protocol, stream, [{:error, stream.reference, reason, true}]} + {{:blocked, _stream_id}, recv_qpack} -> + { + :ok, + %{protocol | recv_qpack: recv_qpack}, + stream, + [{:error, stream.reference, :stream_blocked, true}] + } + + {{:error, reason}, recv_qpack} -> + { + :ok, + %{protocol | recv_qpack: recv_qpack}, + stream, + [{:error, stream.reference, reason, true}] + } end end diff --git a/lib/ankh/protocol/http3/qpack.ex b/lib/ankh/protocol/http3/qpack.ex deleted file mode 100644 index cebf21e..0000000 --- a/lib/ankh/protocol/http3/qpack.ex +++ /dev/null @@ -1,540 +0,0 @@ -defmodule Ankh.Protocol.HTTP3.QPACK do - @moduledoc """ - QPACK header compression for HTTP/3 (RFC 9204), mirroring the HPAX API - used by the HTTP/2 implementation in Ankh. - - ## API mirror - - This module exposes an API that is intentionally identical in shape to - [HPAX](https://hex.pm/packages/hpax) (the HPACK library used for HTTP/2), - making it straightforward to swap between the two: - - | HPAX function | `Ankh.Protocol.HTTP3.QPACK` function | - |---------------------|--------------------------| - | `HPAX.new/1` | `Ankh.Protocol.HTTP3.QPACK.new/1` | - | `HPAX.encode/3` | `Ankh.Protocol.HTTP3.QPACK.encode/3` | - | `HPAX.decode/2` | `Ankh.Protocol.HTTP3.QPACK.decode/2` | - | `HPAX.resize/2` | `Ankh.Protocol.HTTP3.QPACK.resize/2` | - - ## Static-only mode (Required Insert Count = 0) - - This implementation uses **static-only** QPACK, meaning every header block - is encoded with Required Insert Count = 0. No entries are ever inserted - into the dynamic table, so no encoder stream or decoder stream is required. - This mode is always valid per RFC 9204 and is the correct starting point for - an HTTP/3 implementation before dynamic table negotiation is supported. - - Every encoded header block starts with the two-byte prefix `<<0x00, 0x00>>`: - - - Byte 0: Required Insert Count = 0, 8-bit prefix integer → `0x00` - - Byte 1: Sign bit = 0, Delta Base = 0, 7-bit prefix integer → `0x00` - - After the prefix, each header field is encoded as one of: - - 1. **Indexed Field Line** (RFC 9204 §4.5.2) — when both name and value - match a static table entry. First byte: `0xC0 | index` (6-bit prefix, - T = 1 for static). - 2. **Literal with Static Name Reference** (§4.5.4) — when only the name - matches a static table entry. First byte: `0x50 | name_index` (N = 0) - or `0x70 | name_index` (N = 1), 4-bit prefix, T = 1; followed by a - length-prefixed value string. - 3. **Literal without Name Reference** (§4.5.6) — no static table match. - First byte: `0x20 | name_length` (N = 0) or `0x30 | name_length` - (N = 1), 3-bit prefix, H = 0; followed by the raw name bytes and a - length-prefixed value string. - - Huffman encoding is never applied on the encode path; H = 0 (raw bytes) is - always valid. Huffman-encoded strings are decoded correctly on the receive - path. - - ## Usage - - # Initialise a QPACK state (dynamic table disabled by default) - qpack = Ankh.Protocol.HTTP3.QPACK.new() - - # Encode a list of {name, value} headers - {encoded_block, qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, headers, qpack) - - # Decode a received header block binary - {:ok, headers, qpack} = Ankh.Protocol.HTTP3.QPACK.decode(encoded_block, qpack) - - # Resize the dynamic table capacity (for future dynamic-table support) - qpack = Ankh.Protocol.HTTP3.QPACK.resize(qpack, 4096) - - ## Sensitivity - - The `sensitivity` argument to `encode/3` controls the **N** (never-index) - bit in literal field representations: - - - `:store` — the field may be indexed; N = 0 - - `:no_store` — prefer not to index, but it is permitted; N = 0 - - `:never_store` — must never be added to any index; N = 1 - - Indexed field representations do not carry an N bit and are therefore - unaffected by the sensitivity setting. - """ - - import Bitwise - - alias Ankh.Protocol.HTTP3.QPACK.{Huffman, StaticTable, Table} - - @opaque t :: %__MODULE__{ - table: Table.t() - } - - @typedoc "Public alias for `t/0`, mirroring `HPAX.table/0`." - @type table :: t() - - @typedoc """ - Controls whether a literal header field representation carries the - never-index (N) bit. - - - `:store` — N = 0; the field may be indexed by intermediaries - - `:no_store` — N = 0; the encoder prefers not to index, but permits it - - `:never_store` — N = 1; the field must never be indexed - """ - @type sensitivity :: :store | :no_store | :never_store - - @enforce_keys [:table] - defstruct table: nil - - # Two-byte header block prefix for static-only encoding (RFC 9204 §4.5.1): - # Byte 0 — Required Insert Count = 0 encoded with an 8-bit prefix → 0x00 - # Byte 1 — S = 0, Delta Base = 0 encoded with a 7-bit prefix → 0x00 - @prefix <<0x00, 0x00>> - - # --------------------------------------------------------------------------- - # Public API - # --------------------------------------------------------------------------- - - @doc """ - Create a new QPACK state. - - `max_table_capacity` sets the maximum size in bytes of the dynamic table. - Defaults to `0`, which disables the dynamic table entirely and keeps the - encoder in static-only mode. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.new() - %Ankh.Protocol.HTTP3.QPACK{table: %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 0, size: 0}} - - iex> Ankh.Protocol.HTTP3.QPACK.new(4096) - %Ankh.Protocol.HTTP3.QPACK{table: %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 4096, size: 0}} - - """ - @spec new(non_neg_integer()) :: t() - def new(max_table_capacity \\ 0) - when is_integer(max_table_capacity) and max_table_capacity >= 0 do - %__MODULE__{table: Table.new(max_table_capacity)} - end - - @doc """ - Encode a list of header fields into a QPACK header block. - - Returns `{encoded, new_qpack}` where `encoded` is `iodata()` ready to be - written into a QUIC HEADERS frame payload, and `new_qpack` is the updated - QPACK state. - - The header block always begins with the 2-byte static-only prefix - `<<0x00, 0x00>>`, followed by one field representation per `{name, value}` - pair in wire order. - - ### Encoding strategy (in priority order) - - 1. `{name, value}` matches a static table entry exactly → - **Indexed Field Line** (most compact; no N bit). - 2. `name` alone matches a static table entry → - **Literal with Static Name Reference** (N bit set per `sensitivity`). - 3. No static table match → - **Literal without Name Reference** (N bit set per `sensitivity`). - - Strings are always written with H = 0 (raw bytes, no Huffman compression). - - ## Examples - - iex> qpack = Ankh.Protocol.HTTP3.QPACK.new() - iex> {encoded, _qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, [{":method", "GET"}], qpack) - iex> is_list(encoded) - true - - """ - @spec encode(sensitivity(), [{binary(), binary()}], t()) :: {iodata(), t()} - def encode(sensitivity, headers, %__MODULE__{table: table} = qpack) - when sensitivity in [:store, :no_store, :never_store] and is_list(headers) do - never_index = sensitivity == :never_store - - fields = - Enum.map(headers, fn {name, value} -> - encode_field(name, value, never_index) - end) - - {[@prefix | fields], %__MODULE__{qpack | table: table}} - end - - @doc """ - Decode a QPACK-encoded header block. - - Returns `{:ok, headers, new_qpack}` on success, where `headers` is a list - of `{name, value}` binaries in the order they appear in the block. - - Returns `{:error, reason}` on failure: - - - `:invalid_prefix` — `binary` does not begin with `<<0x00, 0x00>>` - - `:dynamic_table_not_supported` — a field references the dynamic table - - `:invalid_static_index` — a static table index is out of range (0–98) - - `:huffman_error` — Huffman decoding failed on a string field - - `:truncated` — the binary ended unexpectedly mid-field - - `:invalid_data` — any other structural decoding error - - ## Examples - - iex> qpack = Ankh.Protocol.HTTP3.QPACK.new() - iex> {encoded, qpack} = Ankh.Protocol.HTTP3.QPACK.encode(:store, [{":method", "GET"}], qpack) - iex> {:ok, headers, _qpack} = Ankh.Protocol.HTTP3.QPACK.decode(IO.iodata_to_binary(encoded), qpack) - iex> headers - [{":method", "GET"}] - - """ - @spec decode(binary(), t()) :: {:ok, [{binary(), binary()}], t()} | {:error, atom()} - def decode(<<0x00, 0x00, rest::binary>>, %__MODULE__{table: table} = qpack) do - case decode_fields(rest, table, []) do - {:ok, headers, new_table} -> {:ok, headers, %__MODULE__{qpack | table: new_table}} - {:error, _} = error -> error - end - rescue - _ -> {:error, :invalid_data} - end - - def decode(_binary, _qpack), do: {:error, :invalid_prefix} - - @doc """ - Resize the QPACK dynamic table to a new maximum capacity. - - Evicts the oldest entries first when the new capacity is smaller than the - current occupied size. Setting `max_table_capacity` to `0` disables the - dynamic table entirely. - - ## Examples - - iex> qpack = Ankh.Protocol.HTTP3.QPACK.new(4096) - iex> qpack = Ankh.Protocol.HTTP3.QPACK.resize(qpack, 0) - iex> qpack.table.max_capacity - 0 - - """ - @spec resize(t(), non_neg_integer()) :: t() - def resize(%__MODULE__{table: table} = qpack, max_table_capacity) - when is_integer(max_table_capacity) and max_table_capacity >= 0 do - %__MODULE__{qpack | table: Table.resize(table, max_table_capacity)} - end - - # --------------------------------------------------------------------------- - # Private: field encoding - # --------------------------------------------------------------------------- - - # Encode a single {name, value} pair using the best available representation. - @spec encode_field(binary(), binary(), boolean()) :: iodata() - defp encode_field(name, value, never_index) do - case StaticTable.find_name_value(name, value) do - {:ok, index} -> - # Exact match → Indexed Field Line (static) — RFC 9204 §4.5.2 - encode_indexed_static(index) - - :error -> - case StaticTable.find_name(name) do - {:ok, name_index} -> - # Name-only match → Literal with Static Name Reference — §4.5.4 - encode_literal_name_ref_static(name_index, value, never_index) - - :error -> - # No match → Literal without Name Reference — §4.5.6 - encode_literal_no_name_ref(name, value, never_index) - end - end - end - - # Indexed Field Line (static) — RFC 9204 §4.5.2 - # - # First-byte layout: - # bit 7 = 1 (Indexed Field Line marker) - # bit 6 = T = 1 (static table reference) - # bits 5:0 = Index (6-bit prefix integer; header byte = 0b11000000 = 0xC0) - @spec encode_indexed_static(non_neg_integer()) :: iodata() - defp encode_indexed_static(index) do - encode_integer(6, 0xC0, index) - end - - # Literal Field Line With Name Reference (static) — RFC 9204 §4.5.4 - # - # First-byte layout: - # bit 7 = 0 - # bit 6 = 1 (Literal With Name Reference marker) - # bit 5 = N (never-index flag) - # bit 4 = T = 1 (static table reference) - # bits 3:0 = Name Index (4-bit prefix integer) - # → header byte = 0x50 when N = 0, 0x70 when N = 1 - # Then: value string (H = 0 bit + 7-bit length + raw bytes) - @spec encode_literal_name_ref_static(non_neg_integer(), binary(), boolean()) :: iodata() - defp encode_literal_name_ref_static(name_index, value, never_index) do - header_byte = if never_index, do: 0x70, else: 0x50 - [encode_integer(4, header_byte, name_index), encode_string(value)] - end - - # Literal Field Line Without Name Reference — RFC 9204 §4.5.6 - # - # First-byte layout: - # bit 7 = 0 - # bit 6 = 0 - # bit 5 = 1 (Literal Without Name Reference marker) - # bit 4 = N (never-index flag) - # bit 3 = H = 0 (no Huffman encoding for the name) - # bits 2:0 = NameLen (3-bit prefix integer) - # → header byte = 0x20 when N = 0, 0x30 when N = 1 - # Then: raw name bytes, then value string (H bit + 7-bit length + raw bytes) - @spec encode_literal_no_name_ref(binary(), binary(), boolean()) :: iodata() - defp encode_literal_no_name_ref(name, value, never_index) do - header_byte = if never_index, do: 0x30, else: 0x20 - [encode_integer(3, header_byte, byte_size(name)), name, encode_string(value)] - end - - # Encode a QPACK string with H = 0 (no Huffman): - # H = 0 is stored in bit 7 of the first byte (header_byte = 0x00). - # The byte count follows as a 7-bit prefix integer. - # The raw bytes follow immediately. - @spec encode_string(binary()) :: iodata() - defp encode_string(value) do - [encode_integer(7, 0x00, byte_size(value)), value] - end - - # --------------------------------------------------------------------------- - # Private: integer encoding (RFC 9204 §4.1.1 ≡ RFC 7541 §5.1) - # --------------------------------------------------------------------------- - - # Encode `value` as a prefix integer with `prefix_bits` bits. - # `header_byte` supplies the fixed high-order flag bits that occupy the - # positions above the prefix in the first encoded byte. - # - # When value < 2^prefix_bits - 1, the whole integer fits in the first byte. - # Otherwise the first byte carries the all-ones sentinel and the remainder is - # expressed in one or more 7-bit continuation bytes. - @spec encode_integer(pos_integer(), non_neg_integer(), non_neg_integer()) :: iodata() - defp encode_integer(prefix_bits, header_byte, value) do - max = (1 <<< prefix_bits) - 1 - - if value < max do - [<>] - else - [<> | encode_integer_continuation(value - max)] - end - end - - # Emit multi-byte continuation octets for the portion of an integer that - # overflowed the prefix. - # - # Each continuation byte encodes 7 bits of the remaining value; the high bit - # (bit 7) is set to 1 when another byte follows, and 0 on the final byte. - # - # When the remainder is exactly 0 (i.e. the original value equalled the - # prefix maximum 2^N - 1), we must still emit <<0>> so the decoder correctly - # terminates the continuation sequence. The `n < 128` guard handles n = 0 - # by producing [<<0>>]. - @spec encode_integer_continuation(non_neg_integer()) :: iolist() - defp encode_integer_continuation(n) when n < 128, do: [<>] - - defp encode_integer_continuation(n) do - [<<(n &&& 0x7F) ||| 0x80>> | encode_integer_continuation(n >>> 7)] - end - - # --------------------------------------------------------------------------- - # Private: field decoding (RFC 9204 §4.5) - # --------------------------------------------------------------------------- - - @spec decode_fields(binary(), Table.t(), [{binary(), binary()}]) :: - {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} - - # All field lines consumed — return accumulated headers in wire order. - defp decode_fields(<<>>, table, acc) do - {:ok, Enum.reverse(acc), table} - end - - # Indexed Field Line — bit 7 = 1 (§4.5.2 static / §4.5.3 post-base dynamic) - defp decode_fields(<<1::1, _::bits>> = data, table, acc) do - decode_indexed(data, table, acc) - end - - # Literal Field Line With Name Reference — bits 7:6 = 01 (§4.5.4 / §4.5.5) - defp decode_fields(<<0::1, 1::1, _::bits>> = data, table, acc) do - decode_literal_name_ref(data, table, acc) - end - - # Literal Field Line Without Name Reference — bits 7:5 = 001 (§4.5.6) - defp decode_fields(<<0::1, 0::1, 1::1, _::bits>> = data, table, acc) do - decode_literal_no_name_ref(data, table, acc) - end - - # Post-Base Indexed Field Line — bits 7:4 = 0001 (§4.5.3, dynamic table only) - defp decode_fields(<<0::1, 0::1, 0::1, 1::1, _::bits>>, _table, _acc) do - {:error, :dynamic_table_not_supported} - end - - # Post-Base Literal Field Line With Name Reference — bits 7:4 = 0000 (§4.5.5) - defp decode_fields(<<0::1, 0::1, 0::1, 0::1, _::bits>>, _table, _acc) do - {:error, :dynamic_table_not_supported} - end - - # Indexed Field Line — RFC 9204 §4.5.2 (static) and §4.5.3 (post-base dynamic) - # - # First-byte layout: 1 T Index(6+) - # bit 6 = T: 1 = static table, 0 = post-base dynamic table - # bits 5:0 = Index as a 6-bit prefix integer - @spec decode_indexed(binary(), Table.t(), [{binary(), binary()}]) :: - {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} - defp decode_indexed(<> = data, table, acc) do - static? = (byte &&& 0x40) != 0 - {index, rest} = decode_integer(6, data) - - if static? do - case StaticTable.lookup(index) do - {:ok, header} -> decode_fields(rest, table, [header | acc]) - :error -> {:error, :invalid_static_index} - end - else - {:error, :dynamic_table_not_supported} - end - end - - # Literal Field Line With Name Reference — RFC 9204 §4.5.4 (static) - # - # First-byte layout: 0 1 N T NameIndex(4+) - # bit 5 = N: never-index flag (informational on decode) - # bit 4 = T: 1 = static table, 0 = dynamic table - # bits 3:0 = Name Index as a 4-bit prefix integer - # Then: value string (H bit + 7-bit length + bytes) - @spec decode_literal_name_ref(binary(), Table.t(), [{binary(), binary()}]) :: - {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} - defp decode_literal_name_ref(<> = data, table, acc) do - static? = (byte &&& 0x10) != 0 - {name_index, rest} = decode_integer(4, data) - - if static? do - with {:ok, {name, _cached_value}} <- StaticTable.lookup(name_index), - {:ok, value, rest2} <- decode_string(rest) do - decode_fields(rest2, table, [{name, value} | acc]) - else - :error -> {:error, :invalid_static_index} - {:error, _} = err -> err - end - else - {:error, :dynamic_table_not_supported} - end - end - - # Literal Field Line Without Name Reference — RFC 9204 §4.5.6 - # - # First-byte layout: 0 0 1 N H NameLen(3+) - # bit 4 = N: never-index flag (informational on decode) - # bit 3 = H: Huffman flag for the name bytes - # bits 2:0 = Name length as a 3-bit prefix integer - # - # The H bit for the name sits at bit 3, which is above the 3-bit prefix - # mask (0x07), so it does not affect the length integer decoded by - # `decode_integer/2` and must be extracted separately. - # - # Then: raw (or Huffman-decoded) name bytes - # Then: value string (H bit + 7-bit length + bytes) - @spec decode_literal_no_name_ref(binary(), Table.t(), [{binary(), binary()}]) :: - {:ok, [{binary(), binary()}], Table.t()} | {:error, atom()} - defp decode_literal_no_name_ref(<> = data, table, acc) do - huffman_name? = (byte &&& 0x08) != 0 - {name_length, rest} = decode_integer(3, data) - - case read_string_data(rest, name_length, huffman_name?) do - {:ok, name, rest2} -> - case decode_string(rest2) do - {:ok, value, rest3} -> decode_fields(rest3, table, [{name, value} | acc]) - {:error, _} = err -> err - end - - {:error, _} = err -> - err - end - end - - # Decode a standard QPACK string: - # bit 7 of the first byte = H (Huffman flag) - # bits 6:0 of the first byte = start of a 7-bit prefix integer for length - # then `length` bytes of string data (raw or Huffman-encoded) - @spec decode_string(binary()) :: {:ok, binary(), binary()} | {:error, atom()} - defp decode_string(<> = data) do - huffman? = (byte &&& 0x80) != 0 - {length, rest} = decode_integer(7, data) - read_string_data(rest, length, huffman?) - end - - defp decode_string(<<>>), do: {:error, :truncated} - - # Read exactly `length` bytes from `data`, applying Huffman decoding when - # `huffman?` is true. Returns `{:error, :truncated}` when fewer than - # `length` bytes are available. - @spec read_string_data(binary(), non_neg_integer(), boolean()) :: - {:ok, binary(), binary()} | {:error, atom()} - defp read_string_data(data, length, huffman?) do - if byte_size(data) < length do - {:error, :truncated} - else - <> = data - apply_huffman(raw, rest, huffman?) - end - end - - @spec apply_huffman(binary(), binary(), boolean()) :: - {:ok, binary(), binary()} | {:error, atom()} - defp apply_huffman(raw, rest, false), do: {:ok, raw, rest} - - defp apply_huffman(raw, rest, true) do - case Huffman.decode(raw) do - {:ok, decoded} -> {:ok, decoded, rest} - {:error, reason} -> {:error, reason} - end - end - - # --------------------------------------------------------------------------- - # Private: integer decoding (RFC 9204 §4.1.1 ≡ RFC 7541 §5.1) - # --------------------------------------------------------------------------- - - # Decode a prefix integer with `prefix_bits` bits from `data`. - # - # The upper bits of the first byte (those above the prefix) are format flags - # and are masked away before inspecting the integer value. When the masked - # value is less than 2^prefix_bits - 1, the integer fits in that single byte. - # When it equals the maximum, additional continuation bytes are consumed. - @spec decode_integer(pos_integer(), binary()) :: {non_neg_integer(), binary()} - defp decode_integer(prefix_bits, <>) do - mask = (1 <<< prefix_bits) - 1 - value = first_byte &&& mask - - if value < mask do - {value, rest} - else - decode_integer_continuation(rest, value, 0) - end - end - - # Accumulate 7-bit continuation bytes into the integer value. - # - # Each byte contributes 7 bits (bits 6:0) shifted left by `shift` positions - # relative to the current accumulator. Bit 7 of each byte indicates whether - # another continuation byte follows (1 = more, 0 = last byte). - @spec decode_integer_continuation(binary(), non_neg_integer(), non_neg_integer()) :: - {non_neg_integer(), binary()} - defp decode_integer_continuation(<>, acc, shift) do - new_acc = acc + ((byte &&& 0x7F) <<< shift) - - if (byte &&& 0x80) != 0 do - decode_integer_continuation(rest, new_acc, shift + 7) - else - {new_acc, rest} - end - end -end diff --git a/lib/ankh/protocol/http3/qpack/huffman.ex b/lib/ankh/protocol/http3/qpack/huffman.ex deleted file mode 100644 index 2d2d73a..0000000 --- a/lib/ankh/protocol/http3/qpack/huffman.ex +++ /dev/null @@ -1,634 +0,0 @@ -defmodule Ankh.Protocol.HTTP3.QPACK.Huffman do - @moduledoc """ - Huffman encoding and decoding for QPACK and HPACK, implementing the static - Huffman code defined in RFC 7541 Appendix B. - - The symbol alphabet has 257 entries: byte values 0–255 plus the EOS - (end-of-string) symbol 256. Code lengths range from 5 bits (common ASCII - printable characters) to 30 bits (EOS and rare control characters). - - ## Encoding - - Each input byte is looked up in the compile-time `@encode_table` to obtain - its `{code_integer, bit_length}` pair. Codes are packed left-to-right into - an accumulator integer; the final incomplete byte is padded on the right - with `1`-bits — the most-significant bits of the EOS code — as required by - RFC 7541 § 5.2. - - ## Decoding - - A prefix trie is built at compile time (`@decode_trie`) from the Huffman - table. The trie is a flat `%{node_id => {child_for_0, child_for_1}}` map - where each child is `{:leaf, symbol}`, `{:node, id}`, or `nil`. Input bits - are consumed one at a time; upon reaching a leaf the decoded byte is emitted - and traversal restarts from the root (node 0). - - At end-of-input, 1–7 trailing `1`-bits are accepted as valid EOS padding - (RFC 7541 § 5.2). Any other trailing trie state, EOS appearing mid-stream, - or an unrecognised bit sequence is rejected with `{:error, :huffman_error}`. - - ### Note on symbol 249 - - A common transcription of the RFC 7541 table writes symbol 249 as - `{0xfffffe, 28}` (6 hex digits). The RFC itself specifies `ffffffe [28]` - (7 hex digits, code integer `0xffffffe`). The shorter form would create a - prefix collision with symbol 49 (`'1'`, 5-bit code `0x1 = 00001`). This - module uses the RFC-correct value `0xffffffe`. - """ - - import Bitwise - - # --------------------------------------------------------------------------- - # RFC 7541 Appendix B Huffman code table - # {symbol, code_integer, code_length_in_bits} - # All 257 entries — symbols 0–255 (byte values) plus 256 (EOS). - # --------------------------------------------------------------------------- - - @table [ - # Control characters 0–31 - {0, 0x1FF8, 13}, - {1, 0x7FFFD8, 23}, - {2, 0xFFFFFE2, 28}, - {3, 0xFFFFFE3, 28}, - {4, 0xFFFFFE4, 28}, - {5, 0xFFFFFE5, 28}, - {6, 0xFFFFFE6, 28}, - {7, 0xFFFFFE7, 28}, - {8, 0xFFFFFE8, 28}, - {9, 0xFFFFEA, 24}, - {10, 0x3FFFFFFC, 30}, - {11, 0xFFFFFE9, 28}, - {12, 0xFFFFFEA, 28}, - {13, 0x3FFFFFFD, 30}, - {14, 0xFFFFFEB, 28}, - {15, 0xFFFFFEC, 28}, - {16, 0xFFFFFED, 28}, - {17, 0xFFFFFEE, 28}, - {18, 0xFFFFFEF, 28}, - {19, 0xFFFFFF0, 28}, - {20, 0xFFFFFF1, 28}, - {21, 0xFFFFFF2, 28}, - {22, 0x3FFFFFFE, 30}, - {23, 0xFFFFFF3, 28}, - {24, 0xFFFFFF4, 28}, - {25, 0xFFFFFF5, 28}, - {26, 0xFFFFFF6, 28}, - {27, 0xFFFFFF7, 28}, - {28, 0xFFFFFF8, 28}, - {29, 0xFFFFFF9, 28}, - {30, 0xFFFFFFA, 28}, - {31, 0xFFFFFFB, 28}, - # Printable ASCII 32–126 - # ' ' - {32, 0x14, 6}, - # '!' - {33, 0x3F8, 10}, - # '"' - {34, 0x3F9, 10}, - # '#' - {35, 0xFFA, 12}, - # '$' - {36, 0x1FF9, 13}, - # '%' - {37, 0x15, 6}, - # '&' - {38, 0xF8, 8}, - # '\'' - {39, 0x7FA, 11}, - # '(' - {40, 0x3FA, 10}, - # ')' - {41, 0x3FB, 10}, - # '*' - {42, 0xF9, 8}, - # '+' - {43, 0x7FB, 11}, - # ',' - {44, 0xFA, 8}, - # '-' - {45, 0x16, 6}, - # '.' - {46, 0x17, 6}, - # '/' - {47, 0x18, 6}, - # '0' - {48, 0x0, 5}, - # '1' - {49, 0x1, 5}, - # '2' - {50, 0x2, 5}, - # '3' - {51, 0x19, 6}, - # '4' - {52, 0x1A, 6}, - # '5' - {53, 0x1B, 6}, - # '6' - {54, 0x1C, 6}, - # '7' - {55, 0x1D, 6}, - # '8' - {56, 0x1E, 6}, - # '9' - {57, 0x1F, 6}, - # ':' - {58, 0x5C, 7}, - # ';' - {59, 0xFB, 8}, - # '<' - {60, 0x7FFC, 15}, - # '=' - {61, 0x20, 6}, - # '>' - {62, 0xFFB, 12}, - # '?' - {63, 0x3FC, 10}, - # '@' - {64, 0x1FFA, 13}, - # 'A' - {65, 0x21, 6}, - # 'B' - {66, 0x5D, 7}, - # 'C' - {67, 0x5E, 7}, - # 'D' - {68, 0x5F, 7}, - # 'E' - {69, 0x60, 7}, - # 'F' - {70, 0x61, 7}, - # 'G' - {71, 0x62, 7}, - # 'H' - {72, 0x63, 7}, - # 'I' - {73, 0x64, 7}, - # 'J' - {74, 0x65, 7}, - # 'K' - {75, 0x66, 7}, - # 'L' - {76, 0x67, 7}, - # 'M' - {77, 0x68, 7}, - # 'N' - {78, 0x69, 7}, - # 'O' - {79, 0x6A, 7}, - # 'P' - {80, 0x6B, 7}, - # 'Q' - {81, 0x6C, 7}, - # 'R' - {82, 0x6D, 7}, - # 'S' - {83, 0x6E, 7}, - # 'T' - {84, 0x6F, 7}, - # 'U' - {85, 0x70, 7}, - # 'V' - {86, 0x71, 7}, - # 'W' - {87, 0x72, 7}, - # 'X' - {88, 0xFC, 8}, - # 'Y' - {89, 0x73, 7}, - # 'Z' - {90, 0xFD, 8}, - # '[' - {91, 0x1FFB, 13}, - # '\' - {92, 0x7FFF0, 19}, - # ']' - {93, 0x1FFC, 13}, - # '^' - {94, 0x3FFC, 14}, - # '_' - {95, 0x22, 6}, - # '`' - {96, 0x7FFD, 15}, - # 'a' - {97, 0x3, 5}, - # 'b' - {98, 0x23, 6}, - # 'c' - {99, 0x4, 5}, - # 'd' - {100, 0x24, 6}, - # 'e' - {101, 0x5, 5}, - # 'f' - {102, 0x25, 6}, - # 'g' - {103, 0x26, 6}, - # 'h' - {104, 0x27, 6}, - # 'i' - {105, 0x6, 5}, - # 'j' - {106, 0x74, 7}, - # 'k' - {107, 0x75, 7}, - # 'l' - {108, 0x28, 6}, - # 'm' - {109, 0x29, 6}, - # 'n' - {110, 0x2A, 6}, - # 'o' - {111, 0x7, 5}, - # 'p' - {112, 0x2B, 6}, - # 'q' - {113, 0x76, 7}, - # 'r' - {114, 0x2C, 6}, - # 's' - {115, 0x8, 5}, - # 't' - {116, 0x9, 5}, - # 'u' - {117, 0x2D, 6}, - # 'v' - {118, 0x77, 7}, - # 'w' - {119, 0x78, 7}, - # 'x' - {120, 0x79, 7}, - # 'y' - {121, 0x7A, 7}, - # 'z' - {122, 0x7B, 7}, - # '{' - {123, 0x7FFE, 15}, - # '|' - {124, 0x7FC, 11}, - # '}' - {125, 0x3FFD, 14}, - # '~' - {126, 0x1FFD, 13}, - # Extended / non-printable 127–255 - {127, 0xFFFFFFC, 28}, - {128, 0xFFFE6, 20}, - {129, 0x3FFFD2, 22}, - {130, 0xFFFE7, 20}, - {131, 0xFFFE8, 20}, - {132, 0x3FFFD3, 22}, - {133, 0x3FFFD4, 22}, - {134, 0x3FFFD5, 22}, - {135, 0x7FFFD9, 23}, - {136, 0x3FFFD6, 22}, - {137, 0x7FFFDA, 23}, - {138, 0x7FFFDB, 23}, - {139, 0x7FFFDC, 23}, - {140, 0x7FFFDD, 23}, - {141, 0x7FFFDE, 23}, - {142, 0xFFFFEB, 24}, - {143, 0x7FFFDF, 23}, - {144, 0xFFFFEC, 24}, - {145, 0xFFFFED, 24}, - {146, 0x3FFFD7, 22}, - {147, 0x7FFFE0, 23}, - {148, 0xFFFFEE, 24}, - {149, 0x7FFFE1, 23}, - {150, 0x7FFFE2, 23}, - {151, 0x7FFFE3, 23}, - {152, 0x7FFFE4, 23}, - {153, 0x1FFFDC, 21}, - {154, 0x3FFFD8, 22}, - {155, 0x7FFFE5, 23}, - {156, 0x3FFFD9, 22}, - {157, 0x7FFFE6, 23}, - {158, 0x7FFFE7, 23}, - {159, 0xFFFFEF, 24}, - {160, 0x3FFFDA, 22}, - {161, 0x1FFFDD, 21}, - {162, 0xFFFE9, 20}, - {163, 0x3FFFDB, 22}, - {164, 0x3FFFDC, 22}, - {165, 0x7FFFE8, 23}, - {166, 0x7FFFE9, 23}, - {167, 0x1FFFDE, 21}, - {168, 0x7FFFEA, 23}, - {169, 0x3FFFDD, 22}, - {170, 0x3FFFDE, 22}, - {171, 0xFFFFF0, 24}, - {172, 0x1FFFDF, 21}, - {173, 0x3FFFDF, 22}, - {174, 0x7FFFEB, 23}, - {175, 0x7FFFEC, 23}, - {176, 0x1FFFE0, 21}, - {177, 0x1FFFE1, 21}, - {178, 0x3FFFE0, 22}, - {179, 0x1FFFE2, 21}, - {180, 0x7FFFED, 23}, - {181, 0x3FFFE1, 22}, - {182, 0x7FFFEE, 23}, - {183, 0x7FFFEF, 23}, - {184, 0xFFFEA, 20}, - {185, 0x3FFFE2, 22}, - {186, 0x3FFFE3, 22}, - {187, 0x3FFFE4, 22}, - {188, 0x7FFFF0, 23}, - {189, 0x3FFFE5, 22}, - {190, 0x3FFFE6, 22}, - {191, 0x7FFFF1, 23}, - {192, 0x3FFFFE0, 26}, - {193, 0x3FFFFE1, 26}, - {194, 0xFFFEB, 20}, - {195, 0x7FFF1, 19}, - {196, 0x3FFFE7, 22}, - {197, 0x7FFFF2, 23}, - {198, 0x3FFFE8, 22}, - {199, 0x1FFFFEC, 25}, - {200, 0x3FFFFE2, 26}, - {201, 0x3FFFFE3, 26}, - {202, 0x3FFFFE4, 26}, - {203, 0x7FFFFDE, 27}, - {204, 0x7FFFFDF, 27}, - {205, 0x3FFFFE5, 26}, - {206, 0xFFFFF1, 24}, - {207, 0x1FFFFED, 25}, - {208, 0x7FFF2, 19}, - {209, 0x1FFFE3, 21}, - {210, 0x3FFFFE6, 26}, - {211, 0x7FFFFE0, 27}, - {212, 0x7FFFFE1, 27}, - {213, 0x3FFFFE7, 26}, - {214, 0x7FFFFE2, 27}, - {215, 0xFFFFF2, 24}, - {216, 0x1FFFE4, 21}, - {217, 0x1FFFE5, 21}, - {218, 0x3FFFFE8, 26}, - {219, 0x3FFFFE9, 26}, - {220, 0xFFFFFFD, 28}, - {221, 0x7FFFFE3, 27}, - {222, 0x7FFFFE4, 27}, - {223, 0x7FFFFE5, 27}, - {224, 0xFFFEC, 20}, - {225, 0xFFFFF3, 24}, - {226, 0xFFFED, 20}, - {227, 0x1FFFE6, 21}, - {228, 0x3FFFE9, 22}, - {229, 0x1FFFE7, 21}, - {230, 0x1FFFE8, 21}, - {231, 0x7FFFF3, 23}, - {232, 0x3FFFEA, 22}, - {233, 0x3FFFEB, 22}, - {234, 0x1FFFFEE, 25}, - {235, 0x1FFFFEF, 25}, - {236, 0xFFFFF4, 24}, - {237, 0xFFFFF5, 24}, - {238, 0x3FFFFEA, 26}, - {239, 0x7FFFF4, 23}, - {240, 0x3FFFFEB, 26}, - {241, 0x7FFFFE6, 27}, - {242, 0x3FFFFEC, 26}, - {243, 0x3FFFFED, 26}, - {244, 0x7FFFFE7, 27}, - {245, 0x7FFFFE8, 27}, - {246, 0x7FFFFE9, 27}, - {247, 0x7FFFFEA, 27}, - {248, 0x7FFFFEB, 27}, - # RFC 7541: ffffffe [28] — 27 ones followed by 0. - # A common transcription error writes this as 0xfffffe (24-bit value) - # which collides with symbol 49 ('1'). The correct value is 0xffffffe. - {249, 0xFFFFFFE, 28}, - {250, 0x7FFFFEC, 27}, - {251, 0x7FFFFED, 27}, - {252, 0x7FFFFEE, 27}, - {253, 0x7FFFFEF, 27}, - {254, 0x7FFFFF0, 27}, - {255, 0x3FFFFEE, 26}, - # EOS — end-of-string; used only as padding prefix, never encoded directly. - {256, 0x3FFFFFFF, 30} - ] - - # --------------------------------------------------------------------------- - # Compile-time encode table: byte_value => {code_integer, code_length_in_bits} - # EOS (256) is excluded — it is never encoded directly. - # --------------------------------------------------------------------------- - - @encode_table (for {sym, code, len} <- @table, sym < 256, into: %{} do - {sym, {code, len}} - end) - - # --------------------------------------------------------------------------- - # Compile-time decode trie. - # - # Representation: %{node_id => {child_for_bit_0, child_for_bit_1}} - # where each child is one of: - # {:leaf, symbol} — a complete Huffman code ends here - # {:node, node_id} — continue traversal - # nil — no valid code takes this branch - # - # Root is node 0; new node IDs are assigned sequentially. - # - # For each table entry we extract the code bits MSB-first, walk the trie - # (creating internal nodes on demand), and place a {:leaf, symbol} at the - # terminal position. - # --------------------------------------------------------------------------- - - @decode_trie Enum.reduce( - @table, - {%{0 => {nil, nil}}, 1}, - fn {symbol, code, len}, {trie, next_id} -> - # Extract bits MSB-first. - bits = for i <- (len - 1)..0//-1, do: band(bsr(code, i), 1) - - # Everything except the last bit forms the path through internal nodes. - {path_bits, [last_bit]} = Enum.split(bits, len - 1) - - # Walk the internal-node path, creating nodes as needed. - # We use `tr`/`nxt` inside the inner reduce to avoid rebinding the - # outer `trie`/`next_id` before we are done with them. - {trie, next_id, leaf_parent} = - Enum.reduce(path_bits, {trie, next_id, 0}, fn bit, {tr, nxt, cur} -> - {c0, c1} = Map.get(tr, cur, {nil, nil}) - existing_child = if bit == 0, do: c0, else: c1 - - {child_id, tr, nxt} = - case existing_child do - {:node, id} -> - # Node already exists; just descend. - {id, tr, nxt} - - nil -> - # Create a fresh internal node and update the parent pointer. - new_id = nxt - tr = Map.put(tr, new_id, {nil, nil}) - - updated_parent = - if bit == 0, - do: {{:node, new_id}, c1}, - else: {c0, {:node, new_id}} - - {new_id, Map.put(tr, cur, updated_parent), nxt + 1} - end - - {tr, nxt, child_id} - end) - - # Place the leaf at the last-bit position of the leaf's parent node. - {c0, c1} = Map.get(trie, leaf_parent, {nil, nil}) - - leaf_entry = - if last_bit == 0, - do: {{:leaf, symbol}, c1}, - else: {c0, {:leaf, symbol}} - - {Map.put(trie, leaf_parent, leaf_entry), next_id} - end - ) - |> elem(0) - - # --------------------------------------------------------------------------- - # Compile-time valid padding terminal nodes. - # - # After the last decoded symbol, the remaining 1–7 bits must be all `1`s - # (the EOS prefix, RFC 7541 § 5.2). In the trie those bits lead exclusively - # rightward (bit = 1) — the same path as EOS (30 ones). We precompute the - # node IDs at depths 1–7 on that all-ones path so that end-of-stream - # validation is a single O(1) MapSet lookup. - # --------------------------------------------------------------------------- - - @padding_valid_nodes Enum.reduce_while( - 1..7, - {0, MapSet.new()}, - fn _, {cur_id, valid_set} -> - case @decode_trie[cur_id] do - {_c0, {:node, next_id}} -> - {:cont, {next_id, MapSet.put(valid_set, next_id)}} - - _ -> - # EOS path shorter than 7 — should not happen with a correct table. - {:halt, {cur_id, valid_set}} - end - end - ) - |> elem(1) - - # --------------------------------------------------------------------------- - # Public API - # --------------------------------------------------------------------------- - - @doc """ - Decode a Huffman-encoded binary according to RFC 7541 Appendix B. - - Returns `{:ok, decoded_binary}` on success. - - Returns `{:error, :huffman_error}` when: - - * an unrecognised bit sequence is encountered; - * the EOS symbol (256) appears mid-stream; - * trailing padding is longer than 7 bits; or - * trailing padding bits are not all `1`s. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.Huffman.decode(<<0x1f>>) - {:ok, "a"} - - iex> Ankh.Protocol.HTTP3.QPACK.Huffman.decode(<<>>) - {:ok, ""} - - """ - @spec decode(binary()) :: {:ok, binary()} | {:error, :huffman_error} - def decode(data) when is_binary(data) do - decode_bits(data, 0, []) - end - - @doc """ - Encode a binary using the RFC 7541 Huffman code. - - Each byte in `data` is replaced by its variable-length Huffman code. The - resulting bit stream is padded on the right to the next byte boundary with - `1`-bits (the EOS prefix), as required by RFC 7541 § 5.2. - - Encoding never fails; every byte value 0–255 has a table entry. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.Huffman.encode("a") - <<0x1f>> - - iex> Ankh.Protocol.HTTP3.QPACK.Huffman.encode("") - <<>> - - """ - @spec encode(binary()) :: binary() - def encode(data) when is_binary(data) do - # Accumulate all codes into a single arbitrary-precision integer together - # with the running bit count. - {bits, length} = - :binary.bin_to_list(data) - |> Enum.reduce({0, 0}, fn byte, {acc_bits, acc_len} -> - {code, len} = Map.fetch!(@encode_table, byte) - {bor(bsl(acc_bits, len), code), acc_len + len} - end) - - # Pad the last byte to a byte boundary with 1-bits. - # rem(8 - rem(length, 8), 8) yields 0 when length is already aligned. - padding = rem(8 - rem(length, 8), 8) - total_bits = length + padding - # bsl(1, padding) - 1 produces `padding` ones; harmless when padding == 0. - padded_bits = bor(bsl(bits, padding), bsl(1, padding) - 1) - - case total_bits do - 0 -> <<>> - n -> <> - end - end - - # --------------------------------------------------------------------------- - # Private: recursive bit-by-bit trie traversal - # --------------------------------------------------------------------------- - - # End of input sitting at the trie root — all symbols decoded, no padding. - defp decode_bits(<<>>, 0, acc) do - {:ok, :erlang.list_to_binary(Enum.reverse(acc))} - end - - # End of input at an intermediate node. Valid iff those bits were 1–7 all-one - # padding bits, i.e. the node is on the all-ones (EOS prefix) path at depth - # 1–7. - defp decode_bits(<<>>, node_id, acc) do - if MapSet.member?(@padding_valid_nodes, node_id) do - {:ok, :erlang.list_to_binary(Enum.reverse(acc))} - else - {:error, :huffman_error} - end - end - - # Consume one bit and advance through the trie. - defp decode_bits(<>, node_id, acc) do - case @decode_trie[node_id] do - nil -> - # node_id not in trie — should not happen with a valid trie. - {:error, :huffman_error} - - {child0, child1} -> - child = if bit == 0, do: child0, else: child1 - - case child do - # EOS appearing mid-stream is always an error (RFC 7541 § 5.2). - {:leaf, 256} -> - {:error, :huffman_error} - - # Decoded a complete symbol; emit it and restart from the root. - {:leaf, sym} -> - decode_bits(rest, 0, [sym | acc]) - - # Partial code; continue deeper in the trie. - {:node, next_id} -> - decode_bits(rest, next_id, acc) - - # No Huffman code starts with this bit sequence. - nil -> - {:error, :huffman_error} - end - end - end -end diff --git a/lib/ankh/protocol/http3/qpack/static_table.ex b/lib/ankh/protocol/http3/qpack/static_table.ex deleted file mode 100644 index 7d0d894..0000000 --- a/lib/ankh/protocol/http3/qpack/static_table.ex +++ /dev/null @@ -1,227 +0,0 @@ -defmodule Ankh.Protocol.HTTP3.QPACK.StaticTable do - @moduledoc """ - QPACK static table as defined in RFC 9204, Appendix A. - - The static table contains 99 pre-defined header field entries (indices 0–98) - that both encoder and decoder share without any negotiation. Using static table - references in QPACK-encoded header blocks saves bytes on the wire by replacing - common header name/value pairs with compact integer indices. - - Three compile-time lookup structures are derived from the raw table: - - * `@by_index` — look up a `{name, value}` pair by its integer index. - * `@by_name_value` — look up the index for an exact `{name, value}` match. - * `@by_name` — look up the lowest index that carries a given header name, - useful when only the name (not the value) can be matched - statically. - - All three maps are built at compile time, so every public function in this - module resolves to a single map lookup at runtime. - """ - - # --------------------------------------------------------------------------- - # Raw table — {index, name, value}, 0-based, ordered by index. - # Source: RFC 9204, Appendix A - # --------------------------------------------------------------------------- - - @table [ - {0, ":authority", ""}, - {1, ":path", "/"}, - {2, "age", "0"}, - {3, "content-disposition", ""}, - {4, "content-length", "0"}, - {5, "cookie", ""}, - {6, "date", ""}, - {7, "etag", ""}, - {8, "if-modified-since", ""}, - {9, "if-none-match", ""}, - {10, "last-modified", ""}, - {11, "link", ""}, - {12, "location", ""}, - {13, "referer", ""}, - {14, "set-cookie", ""}, - {15, ":method", "CONNECT"}, - {16, ":method", "DELETE"}, - {17, ":method", "GET"}, - {18, ":method", "HEAD"}, - {19, ":method", "OPTIONS"}, - {20, ":method", "POST"}, - {21, ":method", "PUT"}, - {22, ":scheme", "http"}, - {23, ":scheme", "https"}, - {24, ":status", "103"}, - {25, ":status", "200"}, - {26, ":status", "304"}, - {27, ":status", "404"}, - {28, ":status", "503"}, - {29, "accept", "*/*"}, - {30, "accept", "application/dns-message"}, - {31, "accept-encoding", "gzip, deflate, br"}, - {32, "accept-ranges", "bytes"}, - {33, "access-control-allow-headers", "cache-control"}, - {34, "access-control-allow-headers", "content-type"}, - {35, "access-control-allow-origin", "*"}, - {36, "cache-control", "max-age=0"}, - {37, "cache-control", "max-age=2592000"}, - {38, "cache-control", "max-age=604800"}, - {39, "cache-control", "no-cache"}, - {40, "cache-control", "no-store"}, - {41, "cache-control", "public, max-age=31536000"}, - {42, "content-encoding", "br"}, - {43, "content-encoding", "gzip"}, - {44, "content-type", "application/dns-message"}, - {45, "content-type", "application/javascript"}, - {46, "content-type", "application/json"}, - {47, "content-type", "application/x-www-form-urlencoded"}, - {48, "content-type", "image/gif"}, - {49, "content-type", "image/jpeg"}, - {50, "content-type", "image/png"}, - {51, "content-type", "text/css"}, - {52, "content-type", "text/html; charset=utf-8"}, - {53, "content-type", "text/plain"}, - {54, "content-type", "text/plain;charset=utf-8"}, - {55, "range", "bytes=0-"}, - {56, "strict-transport-security", "max-age=31536000"}, - {57, "strict-transport-security", "max-age=31536000; includesubdomains"}, - {58, "strict-transport-security", "max-age=31536000; includesubdomains; preload"}, - {59, "vary", "accept-encoding"}, - {60, "vary", "origin"}, - {61, "x-content-type-options", "nosniff"}, - {62, "x-xss-protection", "1; mode=block"}, - {63, ":status", "100"}, - {64, ":status", "204"}, - {65, ":status", "206"}, - {66, ":status", "302"}, - {67, ":status", "400"}, - {68, ":status", "403"}, - {69, ":status", "421"}, - {70, ":status", "425"}, - {71, ":status", "500"}, - {72, "accept-language", ""}, - {73, "access-control-allow-credentials", "FALSE"}, - {74, "access-control-allow-credentials", "TRUE"}, - {75, "access-control-allow-headers", "*"}, - {76, "access-control-allow-methods", "get"}, - {77, "access-control-allow-methods", "get, post, options"}, - {78, "access-control-allow-methods", "options"}, - {79, "access-control-expose-headers", "content-length"}, - {80, "access-control-request-headers", "content-type"}, - {81, "access-control-request-method", "get"}, - {82, "access-control-request-method", "post"}, - {83, "alt-svc", "clear"}, - {84, "authorization", ""}, - {85, "content-security-policy", "script-src 'none'; object-src 'none'; base-uri 'none'"}, - {86, "early-data", "1"}, - {87, "expect-ct", ""}, - {88, "forwarded", ""}, - {89, "if-range", ""}, - {90, "origin", ""}, - {91, "purpose", "prefetch"}, - {92, "server", ""}, - {93, "timing-allow-origin", "*"}, - {94, "upgrade-insecure-requests", "1"}, - {95, "user-agent", ""}, - {96, "x-forwarded-for", ""}, - {97, "x-frame-options", "deny"}, - {98, "x-frame-options", "sameorigin"} - ] - - # --------------------------------------------------------------------------- - # Derived compile-time lookup maps - # --------------------------------------------------------------------------- - - # %{index => {name, value}} - @by_index Map.new(@table, fn {index, name, value} -> {index, {name, value}} end) - - # %{{name, value} => index} - # Iterating in ascending index order and using Map.put_new/3 ensures that - # when multiple entries share the same {name, value} pair, the lowest index - # (i.e. the first occurrence) is retained. - @by_name_value Enum.reduce(@table, %{}, fn {index, name, value}, acc -> - Map.put_new(acc, {name, value}, index) - end) - - # %{name => index} - # Same strategy: first (lowest-index) occurrence wins per name. - @by_name Enum.reduce(@table, %{}, fn {index, name, _value}, acc -> - Map.put_new(acc, name, index) - end) - - # --------------------------------------------------------------------------- - # Public API - # --------------------------------------------------------------------------- - - @doc """ - Look up a static table entry by its zero-based index. - - Returns `{:ok, {name, value}}` when the index exists in the static table, - or `:error` when it is out of range. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(0) - {:ok, {":authority", ""}} - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(17) - {:ok, {":method", "GET"}} - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.lookup(99) - :error - - """ - @spec lookup(non_neg_integer()) :: {:ok, {binary(), binary()}} | :error - def lookup(index) when is_integer(index) and index >= 0 do - case Map.fetch(@by_index, index) do - {:ok, _} = ok -> ok - :error -> :error - end - end - - @doc """ - Find the static table index for an exact header name + value match. - - When the same `{name, value}` pair appears at multiple indices (which does - not happen in the current RFC 9204 static table, but is guarded against), - the lowest index is returned. - - Returns `{:ok, index}` on a match, or `:error` when no entry matches. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name_value(":method", "GET") - {:ok, 17} - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name_value(":method", "PATCH") - :error - - """ - @spec find_name_value(binary(), binary()) :: {:ok, non_neg_integer()} | :error - def find_name_value(name, value) when is_binary(name) and is_binary(value) do - Map.fetch(@by_name_value, {name, value}) - end - - @doc """ - Find the lowest static table index that contains the given header name, - regardless of its associated value. - - This is useful during encoding when only the name (not the value) matches a - static table entry, allowing a name-reference with a literal value instead of - a full indexed representation. - - Returns `{:ok, index}` when the name exists in the static table, or `:error` - when no entry with that name is present. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name(":method") - {:ok, 15} - - iex> Ankh.Protocol.HTTP3.QPACK.StaticTable.find_name("x-custom-header") - :error - - """ - @spec find_name(binary()) :: {:ok, non_neg_integer()} | :error - def find_name(name) when is_binary(name) do - Map.fetch(@by_name, name) - end -end diff --git a/lib/ankh/protocol/http3/qpack/table.ex b/lib/ankh/protocol/http3/qpack/table.ex deleted file mode 100644 index 0863034..0000000 --- a/lib/ankh/protocol/http3/qpack/table.ex +++ /dev/null @@ -1,272 +0,0 @@ -defmodule Ankh.Protocol.HTTP3.QPACK.Table do - @moduledoc """ - QPACK dynamic table as described in RFC 9204, Section 2.2. - - ## Overview - - The QPACK dynamic table stores header field entries that accumulate over the - lifetime of an HTTP/3 connection. Unlike HPACK (RFC 7541), QPACK maintains - *separate* encoder and decoder tables that are synchronised via dedicated - unidirectional QUIC streams (the encoder stream and the decoder stream). This - separation allows the encoder to insert entries without blocking request - processing, at the cost of a more complex acknowledgement protocol. - - Because of this split, instances of this module represent only *one side* of - that pair. The `Ankh.Protocol.HTTP3.QPACK` encoder and decoder each own their own - `Ankh.Protocol.HTTP3.QPACK.Table` and keep them in sync by exchanging instructions on the - control streams. - - ## Capacity and the static-only mode - - When `max_capacity` is `0`—the default before the encoder sends a - `Set Dynamic Table Capacity` instruction—no entries can be inserted and every - header block must be encoded using static table references or literal field - representations. This is the mode currently used by `Ankh.Protocol.HTTP3.QPACK`. - - ## Entry ordering and size - - Entries are stored newest-first (the head of `entries` is the most recently - inserted entry), mirroring the HPACK convention. Each entry occupies: - - byte_size(name) + byte_size(value) + 32 bytes - - The 32-byte overhead accounts for estimated per-entry metadata, as defined in - RFC 9204 § 3.2.1 (the same rule as HPACK § 4.1). - - ## insert_count - - `insert_count` is a monotonically increasing counter of all insertions ever - made into the table. It is never decremented by evictions. QPACK uses it to - derive the **Required Insert Count** field of encoded header blocks, which - tells the decoder the minimum table state needed to decode a block - (RFC 9204 § 3.2.6). - - ## Relative indexing - - QPACK addresses dynamic table entries with a *relative* index that is always - interpreted with respect to a `base` value (the insertion count at encoding - time). Relative index `0` refers to the most recently inserted entry when the - header block was produced. See `lookup_relative/3` for the exact mapping. - """ - - @enforce_keys [:max_capacity] - defstruct entries: [], - size: 0, - max_capacity: 0, - insert_count: 0 - - @type t :: %__MODULE__{ - entries: [{binary(), binary()}], - size: non_neg_integer(), - max_capacity: non_neg_integer(), - insert_count: non_neg_integer() - } - - # Per-entry size overhead defined in RFC 9204 § 3.2.1 (= HPACK § 4.1). - @entry_overhead 32 - - # --------------------------------------------------------------------------- - # Public API - # --------------------------------------------------------------------------- - - @doc """ - Create a new, empty dynamic table with the given maximum capacity in bytes. - - When `max_capacity` is `0`, the table is effectively disabled: any attempt to - insert an entry will return `{:error, :too_large}` because no entry can ever - fit within a zero-byte budget. - - ## Examples - - iex> Ankh.Protocol.HTTP3.QPACK.Table.new(4096) - %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 4096, size: 0, insert_count: 0, entries: []} - - iex> Ankh.Protocol.HTTP3.QPACK.Table.new(0) - %Ankh.Protocol.HTTP3.QPACK.Table{max_capacity: 0, size: 0, insert_count: 0, entries: []} - - """ - @spec new(non_neg_integer()) :: t() - def new(max_capacity) when is_integer(max_capacity) and max_capacity >= 0 do - %__MODULE__{max_capacity: max_capacity} - end - - @doc """ - Insert a new header field entry `{name, value}` into the dynamic table. - - The entry is prepended to `entries` (newest-first ordering). Before inserting, - the oldest entries are evicted one by one—from the tail of the list—until the - new entry can fit within `max_capacity`. - - Returns `{:error, :too_large}` when the entry's own size exceeds - `max_capacity`, meaning the table could never accommodate it regardless of - how many entries are evicted. - - ## Entry size formula - - byte_size(name) + byte_size(value) + 32 - - ## Examples - - iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) - iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "content-type", "text/plain") - iex> Ankh.Protocol.HTTP3.QPACK.Table.insert_count(table) - 1 - iex> Ankh.Protocol.HTTP3.QPACK.Table.size(table) - byte_size("content-type") + byte_size("text/plain") + 32 - - iex> Ankh.Protocol.HTTP3.QPACK.Table.insert(Ankh.Protocol.HTTP3.QPACK.Table.new(0), "x", "y") - {:error, :too_large} - - """ - @spec insert(t(), binary(), binary()) :: {:ok, t()} | {:error, :too_large} - def insert(%__MODULE__{max_capacity: max_capacity} = table, name, value) - when is_binary(name) and is_binary(value) do - entry_size = byte_size(name) + byte_size(value) + @entry_overhead - - if entry_size > max_capacity do - {:error, :too_large} - else - %__MODULE__{} = table = evict_to_fit(table, max_capacity - entry_size) - - {:ok, - %__MODULE__{ - table - | entries: [{name, value} | table.entries], - size: table.size + entry_size, - insert_count: table.insert_count + 1 - }} - end - end - - @doc """ - Look up a dynamic table entry by its QPACK relative index. - - ## Relative indexing - - QPACK's relative index `0` always refers to the most recently inserted entry - *at the time the header block was encoded*. The `base` argument captures that - moment: it is the `insert_count` value used as the encoding base (i.e. - Required Insert Count − 1, per RFC 9204 § 3.2.6). - - The absolute index of the entry is derived as: - - absolute_index = base - 1 - relative_index - - which is then mapped to a position in the newest-first `entries` list: - - list_position = insert_count - 1 - absolute_index - - Returns `{:ok, {name, value}}` when the entry exists and has not been evicted, - or `:error` when the index falls outside the live range of the table. - - ## Examples - - iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) - iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "a", "1") - iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "b", "2") - iex> # insert_count is 2; use base = 2 - iex> # relative 0 → absolute 1 → "b" (newest) - iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 0, 2) - {:ok, {"b", "2"}} - iex> # relative 1 → absolute 0 → "a" (oldest) - iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 1, 2) - {:ok, {"a", "1"}} - iex> Ankh.Protocol.HTTP3.QPACK.Table.lookup_relative(table, 2, 2) - :error - - """ - @spec lookup_relative(t(), non_neg_integer(), non_neg_integer()) :: - {:ok, {binary(), binary()}} | :error - def lookup_relative( - %__MODULE__{entries: entries, insert_count: insert_count}, - relative_index, - base - ) - when is_integer(relative_index) and relative_index >= 0 and - is_integer(base) and base >= 0 do - # Convert QPACK relative index + base to an absolute (0-based) insertion index. - absolute_index = base - 1 - relative_index - - # Map absolute index to a position in the newest-first entries list. - # Entry at list position p has absolute index (insert_count - 1 - p). - list_pos = insert_count - 1 - absolute_index - - if absolute_index >= 0 and list_pos >= 0 and list_pos < length(entries) do - {:ok, Enum.at(entries, list_pos)} - else - :error - end - end - - @doc """ - Return the current occupied size of the dynamic table in bytes. - """ - @spec size(t()) :: non_neg_integer() - def size(%__MODULE__{size: size}), do: size - - @doc """ - Return the maximum capacity of the dynamic table in bytes. - """ - @spec max_capacity(t()) :: non_neg_integer() - def max_capacity(%__MODULE__{max_capacity: max_capacity}), do: max_capacity - - @doc """ - Return the total number of entries ever inserted into the table. - - This counter is monotonically increasing and is never decremented by - evictions. It is used to derive the **Required Insert Count** for any header - block that references dynamic table entries (RFC 9204 § 3.2.6). - """ - @spec insert_count(t()) :: non_neg_integer() - def insert_count(%__MODULE__{insert_count: insert_count}), do: insert_count - - @doc """ - Change the maximum capacity of the dynamic table to `new_max` bytes. - - When `new_max` is smaller than the current occupied `size`, the oldest - entries are evicted until the table fits within the new limit. `insert_count` - is unaffected; evictions never reset it. - - ## Examples - - iex> table = Ankh.Protocol.HTTP3.QPACK.Table.new(4096) - iex> {:ok, table} = Ankh.Protocol.HTTP3.QPACK.Table.insert(table, "content-type", "text/plain") - iex> table = Ankh.Protocol.HTTP3.QPACK.Table.resize(table, 0) - iex> Ankh.Protocol.HTTP3.QPACK.Table.size(table) - 0 - iex> Ankh.Protocol.HTTP3.QPACK.Table.insert_count(table) - 1 - - """ - @spec resize(t(), non_neg_integer()) :: t() - def resize(%__MODULE__{} = table, new_max) - when is_integer(new_max) and new_max >= 0 do - %__MODULE__{table | max_capacity: new_max} - |> evict_to_fit(new_max) - end - - # --------------------------------------------------------------------------- - # Private helpers - # --------------------------------------------------------------------------- - - # Evict the oldest entries (tail of the newest-first list) one at a time - # until the occupied size is at or below `target_size`. - # - # When the table is already within budget, or there are no more entries to - # drop, this is a no-op. - @spec evict_to_fit(t(), non_neg_integer()) :: t() - defp evict_to_fit(%__MODULE__{size: size} = table, target_size) - when size <= target_size, - do: table - - defp evict_to_fit(%__MODULE__{entries: []} = table, _target_size), do: table - - defp evict_to_fit(%__MODULE__{entries: entries, size: size} = table, target_size) do - # Entries are newest-first; the oldest entry to evict is at the tail. - {oldest_name, oldest_value} = List.last(entries) - oldest_entry_size = byte_size(oldest_name) + byte_size(oldest_value) + @entry_overhead - - %__MODULE__{table | entries: :lists.droplast(entries), size: size - oldest_entry_size} - |> evict_to_fit(target_size) - end -end diff --git a/lib/ankh/protocol/http3/stream.ex b/lib/ankh/protocol/http3/stream.ex index 523d179..b5a598b 100644 --- a/lib/ankh/protocol/http3/stream.ex +++ b/lib/ankh/protocol/http3/stream.ex @@ -43,12 +43,12 @@ defmodule Ankh.Protocol.HTTP3.Stream do QUIC streams support independent half-closes on each side: - * `remote_fin/1` — call when the peer signals `peer_send_shutdown` (it has + * `remote_fin/1` — call when the peer signals end-of-stream (it has finished sending). Transitions `:open → :half_closed_remote` and `:half_closed_local → :closed`. * `local_fin/1` — call after sending a FIN (e.g. via - `:quicer.shutdown_stream/1`). Transitions `:open → :half_closed_local` - and `:half_closed_remote → :closed`. + `Ankh.Transport.QUIC.shutdown_stream/1`). Transitions + `:open → :half_closed_local` and `:half_closed_remote → :closed`. States that are already past the relevant half-close are left unchanged. @@ -114,7 +114,7 @@ defmodule Ankh.Protocol.HTTP3.Stream do @typedoc "Per-QUIC-stream HTTP/3 state struct." @type t :: %__MODULE__{ - handle: :quicer.stream_handle() | nil, + handle: non_neg_integer() | nil, reference: reference() | nil, state: state(), kind: stream_kind(), @@ -152,7 +152,7 @@ defmodule Ankh.Protocol.HTTP3.Stream do iex> stream.state :open """ - @spec new_request(handle :: :quicer.stream_handle()) :: t() + @spec new_request(handle :: non_neg_integer()) :: t() def new_request(handle) do %__MODULE__{ handle: handle, @@ -182,7 +182,7 @@ defmodule Ankh.Protocol.HTTP3.Stream do iex> stream.kind :unknown_unidirectional """ - @spec new_incoming(handle :: :quicer.stream_handle(), unidirectional? :: boolean()) :: t() + @spec new_incoming(handle :: non_neg_integer(), unidirectional? :: boolean()) :: t() def new_incoming(handle, false = _unidirectional?) do %__MODULE__{ handle: handle, @@ -300,8 +300,8 @@ defmodule Ankh.Protocol.HTTP3.Stream do @doc """ Records that the remote peer has finished sending on this stream. - This is typically triggered by a `{:quic, :peer_send_shutdown, …}` message - from the quicer NIF. + This is typically triggered when the peer sets `fin=true` on the final + `{:quic, conn, {:stream_data, stream_id, data, true}}` message. State transitions: @@ -332,7 +332,7 @@ defmodule Ankh.Protocol.HTTP3.Stream do Records that the local side has finished sending on this stream. Call this after successfully writing a FIN to the QUIC layer (e.g. via - `:quicer.shutdown_stream/1` or the `fin` flag on the last `:quicer.send/3`). + `Ankh.Transport.QUIC.shutdown_stream/1`). State transitions: diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index ae9c73c..be13e12 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -1,67 +1,65 @@ defmodule Ankh.Transport.QUIC do @moduledoc """ - QUIC transport implementation using the `quicer` library. + QUIC transport implementation using the `erlang_quic` library. - This transport uses `quicer` (https://github.com/emqx/quic), an Erlang/Elixir - binding for MsQuic — Microsoft's cross-platform QUIC implementation. + This transport uses `erlang_quic` (https://github.com/benoitc/erlang_quic), a + pure-Erlang QUIC implementation (RFC 9000 / 9001). ## Differences from TCP/TLS Unlike TCP or TLS transports, which expose a single bytestream socket, QUIC is inherently multiplexed. The QUIC transport therefore manages two distinct handles: - * A **connection handle** (`t:connection/0`) — the underlying QUIC connection. - * A **stream handle** (`t:stream/0`) — a single bidirectional QUIC stream used - for data exchange within that connection. + * A **connection handle** (`t:connection/0`) — the underlying QUIC connection + (a `pid()`). + * A **stream handle** (`t:stream/0`) — a non-negative integer identifying one + bidirectional QUIC stream used for data exchange within that connection. For the client path, both handles are established during `connect/4`. For the server path, the connection handle is set via `new/2` (typically after accepting a raw QUIC connection from a listener), and the stream handle is obtained by calling `accept/2`, which waits for the remote peer to open a stream. - ## Active mode and message handling + ## Message delivery - QUIC streams are kept in `active: :once` mode, mirroring the behaviour of the - TCP and TLS transports. After each `{:quic, data, stream, props}` message is - processed by `handle_msg/2` the stream is immediately re-armed for the next - delivery. Callers should therefore drive the transport through `handle_msg/2` - rather than `recv/3` when operating in message-passing mode. + `erlang_quic` delivers all QUIC events as messages to the owning process + automatically — there is no `active: :once` mode to re-arm. After each message + the process can simply call `handle_msg/2` to decode it. ## ALPN - The default ALPN token is `"h3"` (HTTP/3). This can be overridden via the - `:alpn` option passed to `connect/4`. + The default ALPN token is `"h3"` (HTTP/3) as a binary string. ## Dependencies - Requires the `quicer` optional dependency to be present **and started** (i.e. - the `:quicer` OTP application must be running). + Requires the `quic` optional dependency (erlang_quic) to be present and started. """ require Logger alias Ankh.Transport - @typedoc "QUIC connection handle (opaque reference returned by quicer)" - @type connection :: reference() + @typedoc "QUIC connection handle (pid returned by erlang_quic)" + @type connection :: pid() - @typedoc "QUIC stream handle (opaque reference returned by quicer)" - @type stream :: reference() + @typedoc "QUIC stream identifier (non-negative integer returned by erlang_quic)" + @type stream :: non_neg_integer() @type t :: %__MODULE__{ connection: connection() | nil, - stream: stream() | nil + stream: stream() | nil, + alpn: String.t() | nil } - defstruct connection: nil, stream: nil + defstruct connection: nil, stream: nil, alpn: nil # --------------------------------------------------------------------------- # HTTP/3 stream-management helpers # - # These functions are NOT part of the Ankh.Transport protocol. They are + # These functions are NOT part of the Ankh.Transport protocol. They are # public module-level functions that the Ankh.Protocol.HTTP3 implementation # uses to manage the multiple concurrent QUIC streams that HTTP/3 requires, - # without reaching into the :quicer NIF directly. + # without reaching into the :quic module directly. # --------------------------------------------------------------------------- @doc """ @@ -69,13 +67,12 @@ defmodule Ankh.Transport.QUIC do transport struct with that stream handle set. Used by `Ankh.Protocol.HTTP3.request/2` to obtain a fresh stream for each - HTTP/3 request. The stream is armed with `active: :once` so the first - incoming message is delivered automatically. + HTTP/3 request. """ @spec open_stream(t()) :: {:ok, t()} | {:error, any()} def open_stream(%__MODULE__{connection: conn} = transport) when not is_nil(conn) do - case :quicer.start_stream(conn, %{active: :once}) do - {:ok, handle} -> {:ok, %{transport | stream: handle}} + case :quic.open_stream(conn) do + {:ok, stream_id} -> {:ok, %{transport | stream: stream_id}} {:error, _} = error -> error end end @@ -85,20 +82,13 @@ defmodule Ankh.Transport.QUIC do writes `preface` on it. Used by the HTTP/3 protocol to set up the control stream immediately after - a connection is established. The stream is opened in passive mode (`active: - false`) because it is write-only from this endpoint's perspective; data - arriving on peer-initiated unidirectional streams is delivered via the - `:new_stream` / binary-data QUIC messages instead. + a connection is established. """ @spec open_unidirectional_stream(t(), iodata()) :: :ok | {:error, any()} def open_unidirectional_stream(%__MODULE__{connection: conn}, preface) when not is_nil(conn) do - # open_flag: 1 is QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL - with {:ok, handle} <- :quicer.start_stream(conn, %{open_flag: 1, active: false}) do - case :quicer.send(handle, IO.iodata_to_binary(preface)) do - {:ok, _bytes} -> :ok - {:error, _} = error -> error - end + with {:ok, stream_id} <- :quic.open_unidirectional_stream(conn) do + :quic.send_data(conn, stream_id, IO.iodata_to_binary(preface), false) end end @@ -106,43 +96,18 @@ defmodule Ankh.Transport.QUIC do Gracefully shuts down the **send side** of the stored stream (QUIC FIN), signalling to the peer that no more data will be sent. - This does **not** close the connection. Used after sending the last frame - on a request or response stream so the peer knows the message is complete. + This does **not** close the connection. """ @spec shutdown_stream(t()) :: :ok | {:error, any()} - def shutdown_stream(%__MODULE__{stream: stream}) when not is_nil(stream) do - :quicer.shutdown_stream(stream, 5_000) - end - - @doc """ - Re-arms `active: :once` mode on the stored stream after a single QUIC data - message has been delivered. - - Must be called inside the `stream/2` handler each time a - `{:quic, data, stream_handle, props}` message is processed, so that the - next incoming message is delivered to the owning process. - """ - @spec rearm_stream(t()) :: :ok - def rearm_stream(%__MODULE__{stream: stream}) when not is_nil(stream) do - case :quicer.setopt(stream, :active, :once) do - :ok -> - :ok - - {:error, reason} -> - Logger.debug("QUIC stream re-arm error: #{inspect(reason)}") - :ok - end + def shutdown_stream(%__MODULE__{connection: conn, stream: stream}) + when not is_nil(conn) and not is_nil(stream) do + :quic.send_data(conn, stream, <<>>, true) end defimpl Transport do # Default ALPN for HTTP/3. @default_alpn ["h3"] - # Stream options used when opening or accepting a stream. - # `active: :once` delivers exactly one message then disarms, matching - # the behaviour of the TCP/TLS transports. - @default_stream_opts [active: :once] - # --------------------------------------------------------------------------- # new/2 # --------------------------------------------------------------------------- @@ -152,21 +117,19 @@ defmodule Ankh.Transport.QUIC do Accepts either: - * A bare `connection_handle()` — used on the server path where the - connection has already been accepted from a listener. The stream - field is left `nil` and must be populated via `accept/2` before - data can flow. + * A bare `connection()` (pid) — used on the server path where the + connection has already been accepted from a listener. The stream field is + left `nil` and must be populated via `accept/2` before data can flow. - * A `{connection_handle(), stream_handle()}` tuple — used when both - handles are already known (e.g. when handing an established session - off to another process). + * A `{connection(), stream()}` tuple — used when both handles are already + known. """ def new(%@for{} = transport, {connection, stream}) - when is_reference(connection) and is_reference(stream) do + when is_pid(connection) and is_integer(stream) do {:ok, %{transport | connection: connection, stream: stream}} end - def new(%@for{} = transport, connection) when is_reference(connection) do + def new(%@for{} = transport, connection) when is_pid(connection) do {:ok, %{transport | connection: connection}} end @@ -177,6 +140,12 @@ defmodule Ankh.Transport.QUIC do @doc """ Opens a QUIC connection to the given `uri`. + `erlang_quic`'s connect is asynchronous: `:quic.connect/4` returns + `{:ok, conn_pid}` immediately and the calling process then receives + `{:quic, conn_pid, {:connected, info}}` when the TLS handshake completes. + This function blocks internally (using `receive … after timeout`) to present + a synchronous interface to the caller. + Returns a transport with only the `connection` field set (`stream: nil`). Streams are not opened automatically; use `Ankh.Transport.QUIC.open_stream/1` or `Ankh.Transport.QUIC.open_unidirectional_stream/2` at the protocol layer @@ -184,47 +153,57 @@ defmodule Ankh.Transport.QUIC do Supported options (all optional): - * `:alpn` — list of ALPN tokens; defaults to `[~c"h3"]`. - Must be charlists (Erlang strings), not Elixir binaries. - * `:verify` — peer certificate verification; defaults to `:verify_peer`. + * `:alpn` — list of ALPN tokens as binaries; defaults to `["h3"]`. + * `:verify` — peer certificate verification; pass `true` or `:verify_peer` + to verify. Defaults to `false`. * `:cacertfile` — path to a CA certificate bundle. * `:certfile` — path to a client certificate (mTLS). * `:keyfile` — path to the private key for the client certificate. * `:password` — passphrase for an encrypted private key. - * `:peer_unidi_stream_count` — number of unidirectional streams the remote - peer is allowed to open; useful for HTTP/3 control/QPACK streams. """ def connect(%@for{} = transport, %URI{host: host, port: port}, timeout, options) do - hostname = String.to_charlist(host) alpn = Keyword.get(options, :alpn, @default_alpn) - verify = Keyword.get(options, :verify, :verify_peer) - - conn_opts = - [alpn: alpn, verify: verify] - |> Keyword.merge( - Keyword.take(options, [ - :cacertfile, - :certfile, - :keyfile, - :password, - :peer_unidi_stream_count, - :peer_bidi_stream_count - ]) - ) - |> Map.new() - case :quicer.connect(hostname, port || 443, conn_opts, timeout) do - {:ok, conn} -> - {:ok, %{transport | connection: conn}} + verify = + case Keyword.get(options, :verify, false) do + :verify_peer -> true + :verify_none -> false + v when is_boolean(v) -> v + _ -> false + end - {:error, :transport_down, _props} -> - {:error, :closed} + conn_opts = + %{alpn: alpn, verify: verify} + |> maybe_put(:cacertfile, Keyword.get(options, :cacertfile)) + |> maybe_put(:certfile, Keyword.get(options, :certfile)) + |> maybe_put(:keyfile, Keyword.get(options, :keyfile)) + |> maybe_put(:password, Keyword.get(options, :password)) + + case :quic.connect(host, port || 443, conn_opts, self()) do + {:ok, conn_pid} -> + receive do + {:quic, ^conn_pid, {:connected, _info}} -> + negotiated = List.first(alpn, "h3") + {:ok, %{transport | connection: conn_pid, alpn: negotiated}} + + {:quic, ^conn_pid, {:closed, reason}} -> + {:error, reason} + + {:quic, ^conn_pid, {:transport_error, _code, reason}} -> + {:error, reason} + after + timeout -> + {:error, :timeout} + end {:error, reason} -> {:error, reason} end end + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + # --------------------------------------------------------------------------- # accept/2 # --------------------------------------------------------------------------- @@ -233,16 +212,21 @@ defmodule Ankh.Transport.QUIC do Accepts an inbound QUIC stream on an already-established connection. The connection handle must have been set beforehand via `new/2`. This call - blocks until the remote peer opens a stream or an error occurs. + blocks until the remote peer opens a stream (delivering + `{:quic, conn, {:stream_opened, stream_id}}`) or until the 5-second timeout + expires. - Any additional `options` are merged with the default stream options and - forwarded to `:quicer.accept_stream/2`. + Note: in HTTP/3 mode streams arrive as asynchronous messages handled by + `Ankh.Protocol.HTTP3.stream/2`, so this function is primarily used for + single-stream protocols (HTTP/1.1, HTTP/2) over QUIC. """ - def accept(%@for{connection: conn} = transport, options) when not is_nil(conn) do - stream_opts = @default_stream_opts |> Keyword.merge(options) |> Map.new() - - with {:ok, stream} <- :quicer.accept_stream(conn, stream_opts) do - {:ok, %{transport | stream: stream}} + def accept(%@for{connection: conn} = transport, _options) when not is_nil(conn) do + receive do + {:quic, ^conn, {:stream_opened, stream_id}} -> + {:ok, %{transport | stream: stream_id}} + after + 5_000 -> + {:error, :timeout} end end @@ -257,15 +241,12 @@ defmodule Ankh.Transport.QUIC do @doc """ Sends `data` over the QUIC stream synchronously. - The data is converted to a binary via `IO.iodata_to_binary/1` before being - handed off to `:quicer.send/2`, which blocks until the send is acknowledged - by the transport layer. + Converts `data` to a binary via `IO.iodata_to_binary/1` and calls + `:quic.send_data/4` with `fin = false`. """ - def send(%@for{stream: stream}, data) when not is_nil(stream) do - case :quicer.send(stream, IO.iodata_to_binary(data)) do - {:ok, _bytes_sent} -> :ok - {:error, _} = error -> error - end + def send(%@for{connection: conn, stream: stream}, data) + when not is_nil(conn) and not is_nil(stream) do + :quic.send_data(conn, stream, IO.iodata_to_binary(data), false) end def send(%@for{stream: nil}, _data) do @@ -277,16 +258,21 @@ defmodule Ankh.Transport.QUIC do # --------------------------------------------------------------------------- @doc """ - Passively reads up to `size` bytes from the QUIC stream. - - Pass `size = 0` to retrieve all currently available data in the receive - buffer (the quicer default behaviour for a zero-length read). + Passively reads data from the QUIC stream by blocking on the next + `{:quic, conn, {:stream_data, stream_id, data, _fin}}` message. - The `timeout` argument is accepted for interface compatibility but is not - forwarded to quicer; quicer manages its own internal timeouts. + The `size` argument is accepted for interface compatibility but is not + enforced — `erlang_quic` does not support partial reads. """ - def recv(%@for{stream: stream}, size, _timeout) when not is_nil(stream) do - :quicer.recv(stream, size) + def recv(%@for{connection: conn, stream: stream}, _size, timeout) + when not is_nil(conn) and not is_nil(stream) do + receive do + {:quic, ^conn, {:stream_data, ^stream, data, _fin}} -> + {:ok, data} + after + timeout -> + {:error, :timeout} + end end def recv(%@for{stream: nil}, _size, _timeout) do @@ -298,33 +284,14 @@ defmodule Ankh.Transport.QUIC do # --------------------------------------------------------------------------- @doc """ - Gracefully closes the QUIC stream and then the underlying connection. + Closes the underlying QUIC connection. - The stream is shut down with a FIN (graceful half-close of the send side) - before the connection is closed. Errors from individual close calls are - logged but do not prevent the other handle from being closed. + Unlike the quicer-based implementation there is no separate stream-close + call; `:quic.close/1` tears down the whole connection. """ - def close(%@for{connection: conn, stream: stream} = transport) do - if not is_nil(stream) do - case :quicer.close_stream(stream) do - :ok -> - :ok - - {:error, reason} = error -> - Logger.debug("QUIC stream close error: #{inspect(reason)}") - error - end - end - + def close(%@for{connection: conn} = transport) do if not is_nil(conn) do - case :quicer.close_connection(conn) do - :ok -> - :ok - - {:error, reason} = error -> - Logger.debug("QUIC connection close error: #{inspect(reason)}") - error - end + :quic.close(conn) end {:ok, %{transport | connection: nil, stream: nil}} @@ -335,73 +302,64 @@ defmodule Ankh.Transport.QUIC do # --------------------------------------------------------------------------- @doc """ - Handles asynchronous messages delivered by the quicer NIF. + Handles asynchronous messages delivered by `erlang_quic`. ## Handled messages - * `{:quic, data, stream, props}` — binary data arrived on our stream. - The stream is re-armed for the next `active: :once` delivery before - returning `{:ok, data}`. - * `{:quic, :peer_send_shutdown, stream, _}` — the remote peer has - half-closed its send side (sent a FIN). - * `{:quic, :peer_send_aborted, stream, _}` — the remote peer aborted - its send side. - * `{:quic, :stream_closed, stream, _}` — the stream has been fully - closed by both sides. - * `{:quic, :transport_shutdown, conn, _}` — the QUIC connection was - shut down by the transport (e.g. idle timeout, network loss). - * `{:quic, :closed, conn, _}` — the QUIC connection was closed. + * `{:quic, conn, {:stream_data, stream_id, data, false}}` — binary data + arrived on our stream. Returns `{:ok, data}`. + * `{:quic, conn, {:stream_data, stream_id, data, true}}` — final data + chunk with FIN. Returns `{:ok, data}` if non-empty, `{:error, :closed}` + otherwise. + * `{:quic, conn, {:stream_reset, stream_id, _}}` — stream reset by peer. + * `{:quic, conn, {:closed, _}}` — connection closed. + * `{:quic, conn, {:transport_error, _, _}}` — transport-level error. All other messages return `{:other, msg}` so the caller can handle them. """ # HTTP/3 passthrough mode: when no specific stream is stored, the QUIC # transport is operating as a bare connection handle on behalf of the - # HTTP/3 protocol layer. Pass every QUIC message through as-is so that - # `Ankh.Protocol.HTTP3.stream/2` can inspect the stream handle and - # dispatch to the correct per-request stream state machine. - def handle_msg(%@for{stream: nil}, {:quic, _, _, _} = msg), do: {:ok, msg} - - # Binary data on our stream — re-arm active mode and return the payload. - def handle_msg(%@for{stream: stream}, {:quic, data, stream, _props}) + # HTTP/3 protocol layer. Pass every QUIC message through as-is so that + # `Ankh.Protocol.HTTP3.stream/2` can dispatch to the correct per-request + # stream state machine. + def handle_msg(%@for{stream: nil}, {:quic, _, _} = msg), do: {:ok, msg} + + # Binary data on our stream, no FIN. + def handle_msg( + %@for{connection: conn, stream: stream}, + {:quic, conn, {:stream_data, stream, data, false}} + ) when is_binary(data) and not is_nil(stream) do - case :quicer.setopt(stream, :active, :once) do - :ok -> - {:ok, data} - - {:error, reason} -> - Logger.debug("QUIC stream re-arm error: #{inspect(reason)}") - {:ok, data} - end - end - - # Remote peer finished sending (graceful FIN). - def handle_msg(%@for{stream: stream}, {:quic, :peer_send_shutdown, stream, _props}) - when not is_nil(stream) do - {:error, :closed} + {:ok, data} end - # Remote peer aborted its send side. - def handle_msg(%@for{stream: stream}, {:quic, :peer_send_aborted, stream, _error_code}) - when not is_nil(stream) do - {:error, :closed} + # Final data chunk with FIN. + def handle_msg( + %@for{connection: conn, stream: stream}, + {:quic, conn, {:stream_data, stream, data, true}} + ) + when is_binary(data) and not is_nil(stream) do + if data != <<>>, do: {:ok, data}, else: {:error, :closed} end - # Stream has been fully closed. - def handle_msg(%@for{stream: stream}, {:quic, :stream_closed, stream, _props}) + # Stream reset by peer. + def handle_msg( + %@for{connection: conn, stream: stream}, + {:quic, conn, {:stream_reset, stream, _error_code}} + ) when not is_nil(stream) do {:error, :closed} end - # Transport-level shutdown (e.g. idle timeout, network error). - def handle_msg(%@for{connection: conn}, {:quic, :transport_shutdown, conn, props}) + # Connection closed. + def handle_msg(%@for{connection: conn}, {:quic, conn, {:closed, _reason}}) when not is_nil(conn) do - Logger.debug("QUIC transport shutdown: #{inspect(props)}") {:error, :closed} end - # Connection closed. - def handle_msg(%@for{connection: conn}, {:quic, :closed, conn, _props}) + # Transport error. + def handle_msg(%@for{connection: conn}, {:quic, conn, {:transport_error, _code, _reason}}) when not is_nil(conn) do {:error, :closed} end @@ -414,23 +372,19 @@ defmodule Ankh.Transport.QUIC do # --------------------------------------------------------------------------- @doc """ - Returns the ALPN protocol negotiated during the QUIC handshake. + Returns the ALPN protocol stored during `connect/4`, or `"h3"` if this + transport was initialised on the server side without going through `connect/4`. - Delegates to `:quicer.negotiated_protocol/1` on the connection handle. - Returns `{:error, :protocol_not_negotiated}` if no connection is present. + `erlang_quic` does not expose a `negotiated_protocol/1` query function; + the negotiated ALPN is inferred from the first token in the requested list + (connection succeeds only if the server agrees). """ - @dialyzer {:nowarn_function, [negotiated_protocol: 1]} - def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn) do - case :quicer.negotiated_protocol(conn) do - {:ok, protocol} -> - {:ok, protocol} - - {:error, _reason, _props} -> - {:error, :protocol_not_negotiated} + def negotiated_protocol(%@for{alpn: alpn}) when not is_nil(alpn) do + {:ok, alpn} + end - {:error, _reason} -> - {:error, :protocol_not_negotiated} - end + def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn) do + {:ok, "h3"} end def negotiated_protocol(%@for{connection: nil}) do diff --git a/mix.exs b/mix.exs index c0519d9..fa591aa 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ defmodule Ankh.Mixfile do {:castore, "~> 1.0"}, {:hpax, "~> 1.0"}, {:plug, "~> 1.0"}, - {:quicer, git: "https://github.com/emqx/quic.git", tag: "0.2.16", optional: true}, + {:quic, "~> 1.0", optional: true}, {:credo, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, "~> 0.40.0", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false} diff --git a/mix.lock b/mix.lock index 7ec1539..67dc103 100644 --- a/mix.lock +++ b/mix.lock @@ -17,7 +17,5 @@ "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "quic": {:hex, :quic, "1.6.5", "28b7d49c1732b2de3861701bb03ae7798537240552370ac49f0d139d2ea8f621", [:rebar3], [], "hexpm", "de1a88972c33201a50d1a17c8c4a14528bd1d8f25ef705897d680ae312d0aa78"}, - "quicer": {:git, "https://github.com/emqx/quic.git", "b097389558e425b0ae360d3a25274bda5dcefa71", [tag: "0.2.16"]}, - "snabbkaffe": {:hex, :snabbkaffe, "1.0.10", "9be2f54f61fc6862391b666b2b5b76c3fa53598e2989a17cef1b48cf347a8a63", [:rebar3], [], "hexpm", "70a98df36ae756908d55b5770891d443d63c903833e3e87d544036e13d4fac26"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, } diff --git a/test/ankh/protocol/http3/request_test.exs b/test/ankh/protocol/http3/request_test.exs index 3436165..72e446c 100644 --- a/test/ankh/protocol/http3/request_test.exs +++ b/test/ankh/protocol/http3/request_test.exs @@ -7,15 +7,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do alias Ankh.HTTP alias Ankh.HTTP.Request - # ------------------------------------------------------------------------- - # Setup - # ------------------------------------------------------------------------- - - setup_all do - {:ok, _} = Application.ensure_all_started(:quicer) - :ok - end - # ------------------------------------------------------------------------- # Response helper # ------------------------------------------------------------------------- From 4f7b43923c752f96a34c37fda65499096272674b Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 21 May 2026 12:23:09 +0200 Subject: [PATCH 07/13] erlang_quic requires OTP >= 26 --- lib/ankh/protocol/http2.ex | 4 +- lib/ankh/protocol/http3.ex | 153 +++++++++++------------------- lib/ankh/protocol/http3/frame.ex | 46 ++------- lib/ankh/protocol/http3/stream.ex | 54 +++-------- lib/ankh/transport/quic.ex | 103 ++++---------------- 5 files changed, 98 insertions(+), 262 deletions(-) diff --git a/lib/ankh/protocol/http2.ex b/lib/ankh/protocol/http2.ex index 71d983a..120de3f 100644 --- a/lib/ankh/protocol/http2.ex +++ b/lib/ankh/protocol/http2.ex @@ -345,9 +345,7 @@ defmodule Ankh.Protocol.HTTP2 do end defp process_queued_frame(%@for{send_queue: send_queue} = protocol, %Data{} = frame, size) - when size <= 0 do - {:ok, %{protocol | send_queue: :queue.in_r(frame, send_queue)}} - end + when size <= 0, do: {:ok, %{protocol | send_queue: :queue.in_r(frame, send_queue)}} defp process_queued_frame(protocol, %{stream_id: stream_id} = frame, size) do frame diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index 687c45b..c6ab302 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -121,10 +121,6 @@ defmodule Ankh.Protocol.HTTP3 do alias Ankh.Transport.QUIC require Logger - # ------------------------------------------------------------------------- - # accept/5 — server side - # ------------------------------------------------------------------------- - @doc """ Accepts an incoming QUIC connection and initialises the HTTP/3 server state. @@ -152,10 +148,6 @@ defmodule Ankh.Protocol.HTTP3 do end end - # ------------------------------------------------------------------------- - # connect/4 — client side - # ------------------------------------------------------------------------- - @doc """ Initialises the HTTP/3 client state on an already-established QUIC connection. @@ -166,7 +158,7 @@ defmodule Ankh.Protocol.HTTP3 do """ def connect(%@for{} = protocol, uri, %QUIC{connection: conn} = transport, _options) do with :ok <- QUIC.open_unidirectional_stream(transport, Frame.control_stream_preface()) do - Logger.debug("HTTP/3 client connected: #{inspect(conn)}") + Logger.debug("HTTP/3 connection established: #{inspect(conn)}") {:ok, %@for{ @@ -180,10 +172,6 @@ defmodule Ankh.Protocol.HTTP3 do end end - # ------------------------------------------------------------------------- - # error/1 - # ------------------------------------------------------------------------- - @doc """ Closes the QUIC connection, signalling a connection-level error to the peer. """ @@ -224,17 +212,11 @@ defmodule Ankh.Protocol.HTTP3 do with {:ok, _, headers_frame_data} <- Frame.encode(%Headers{payload: %Headers.Payload{hbf: IO.iodata_to_binary(hbf)}}), - headers_frame_bin = IO.iodata_to_binary(headers_frame_data), {:ok, stream_transport} <- QUIC.open_stream(transport), - :ok <- Transport.send(stream_transport, headers_frame_bin) do + :ok <- Transport.send(stream_transport, headers_frame_data) do if IO.iodata_length(body) > 0 do - body_bin = IO.iodata_to_binary(body) - - {:ok, _, data_frame_data} = - Frame.encode(%Data{payload: %Data.Payload{data: body_bin}}) - - data_frame_bin = IO.iodata_to_binary(data_frame_data) - Transport.send(stream_transport, data_frame_bin) + {:ok, _, data_frame_data} = Frame.encode(%Data{payload: %Data.Payload{data: body}}) + Transport.send(stream_transport, data_frame_data) end # Always FIN the send side — even for no-body requests (e.g. GET). @@ -273,7 +255,7 @@ defmodule Ankh.Protocol.HTTP3 do gracefully shut down (FIN). """ def respond( - %@for{} = protocol, + %@for{transport: %QUIC{}} = protocol, request_reference, %Response{status: status, headers: resp_headers, body: body, trailers: trailers} ) do @@ -285,56 +267,42 @@ defmodule Ankh.Protocol.HTTP3 do {:ok, _, headers_frame_data} = Frame.encode(%Headers{payload: %Headers.Payload{hbf: IO.iodata_to_binary(hbf)}}) - headers_frame_bin = IO.iodata_to_binary(headers_frame_data) - %QUIC{} = quic_transport = protocol.transport - stream_transport = %QUIC{quic_transport | stream: handle} - Transport.send(stream_transport, headers_frame_bin) + transport = %{protocol.transport | stream: handle} + Transport.send(transport, headers_frame_data) if IO.iodata_length(body) > 0 do - body_bin = IO.iodata_to_binary(body) - - {:ok, _, data_frame_data} = - Frame.encode(%Data{payload: %Data.Payload{data: body_bin}}) - - data_frame_bin = IO.iodata_to_binary(data_frame_data) - Transport.send(stream_transport, data_frame_bin) + {:ok, _, data} = Frame.encode(%Data{payload: %Data.Payload{data: body}}) + Transport.send(transport, data) end send_qpack = if trailers != [] do - {trailer_hbf, sq} = :quic_qpack.encode(trailers, send_qpack) - trailer_bin = IO.iodata_to_binary(trailer_hbf) + {trailer_hbf, send_qpack} = :quic_qpack.encode(trailers, send_qpack) {:ok, _, trailer_frame_data} = - Frame.encode(%Headers{payload: %Headers.Payload{hbf: trailer_bin}}) + Frame.encode(%Headers{payload: %Headers.Payload{hbf: trailer_hbf}}) - trailer_frame_bin = IO.iodata_to_binary(trailer_frame_data) - Transport.send(stream_transport, trailer_frame_bin) - sq + Transport.send(transport, trailer_frame_data) + send_qpack else send_qpack end - QUIC.shutdown_stream(stream_transport) - - stream = Stream.local_fin(stream) + QUIC.shutdown_stream(transport) Logger.debug("HTTP/3 response sent: #{status} ref=#{inspect(request_reference)}") - protocol = %@for{ - protocol - | send_qpack: send_qpack, - streams: Map.put(protocol.streams, handle, stream) + { + :ok, + %@for{ + protocol + | send_qpack: send_qpack, + streams: Map.put(protocol.streams, handle, Stream.local_fin(stream)) + } } - - {:ok, protocol} end end - # ------------------------------------------------------------------------- - # stream/2 — incoming QUIC message dispatcher - # ------------------------------------------------------------------------- - @doc """ Dispatches a raw QUIC message and returns HTTP/3 response events. @@ -367,14 +335,12 @@ defmodule Ankh.Protocol.HTTP3 do end # New stream opened by peer (server-side). - def stream(%@for{} = protocol, {:quic, _conn, {:stream_opened, stream_id}}) do - handle_new_stream(protocol, stream_id) - end + def stream(%@for{} = protocol, {:quic, _conn, {:stream_opened, stream_id}}), + do: handle_new_stream(protocol, stream_id) # Peer reset the stream. - def stream(%@for{} = protocol, {:quic, _conn, {:stream_reset, stream_id, _error_code}}) do - handle_stream_closed(protocol, stream_id) - end + def stream(%@for{} = protocol, {:quic, _conn, {:stream_reset, stream_id, _error_code}}), + do: handle_stream_closed(protocol, stream_id) def stream(%@for{} = _protocol, {:quic, _conn, {:transport_error, _code, _reason}}), do: {:error, :closed} @@ -383,10 +349,6 @@ defmodule Ankh.Protocol.HTTP3 do def stream(%@for{} = protocol, _msg), do: {:ok, protocol, []} - # ========================================================================= - # Private helpers - # ========================================================================= - # Resolves an Elixir request reference to its QUIC stream handle and # per-stream state struct. Returns `{:error, :unknown_reference}` when # the reference is not present in the protocol maps. @@ -414,9 +376,7 @@ defmodule Ankh.Protocol.HTTP3 do # the kind, then treat the remainder as HTTP/3 frame data. {stream, effective_data} = maybe_identify_stream_kind(stream, data) - stream = Stream.append(stream, effective_data) - - process_stream_frames(protocol, stream_id, stream) + process_stream_frames(protocol, stream_id, Stream.append(stream, effective_data)) end # Signals end-of-stream to the caller via an empty DATA event with @@ -440,8 +400,8 @@ defmodule Ankh.Protocol.HTTP3 do # Marks both halves of the stream as closed. Called when the peer resets # the stream via a RESET_STREAM frame. - defp handle_stream_closed(%@for{streams: streams} = protocol, stream_id) do - case Map.get(streams, stream_id) do + defp handle_stream_closed(%@for{} = protocol, stream_id) do + case Map.get(protocol.streams, stream_id) do nil -> {:ok, protocol, []} @@ -461,9 +421,7 @@ defmodule Ankh.Protocol.HTTP3 do defp handle_new_stream(%@for{} = protocol, stream_id) do unidirectional? = Bitwise.band(stream_id, 2) != 0 stream = Stream.new_incoming(stream_id, unidirectional?) - Logger.debug("HTTP/3 new stream: id=#{stream_id}, unidirectional=#{unidirectional?}") - {:ok, %@for{protocol | streams: Map.put(protocol.streams, stream_id, stream)}, []} end @@ -473,9 +431,8 @@ defmodule Ankh.Protocol.HTTP3 do defp maybe_identify_stream_kind( %Stream{kind: :unknown_unidirectional} = stream, <> - ) do - {Stream.identify_kind(stream, type_byte), rest} - end + ), + do: {Stream.identify_kind(stream, type_byte), rest} defp maybe_identify_stream_kind(stream, data), do: {stream, data} @@ -509,34 +466,38 @@ defmodule Ankh.Protocol.HTTP3 do |> Enum.reduce_while( {:ok, protocol, stream, []}, fn - {rest, nil}, {:ok, p, s, responses} -> + {rest, nil}, {:ok, protocol, stream, responses} -> # Partial frame — store remainder and stop. - {:halt, {:ok, p, Stream.consume_buffer(s, rest), responses}} + {:halt, {:ok, protocol, Stream.consume_buffer(stream, rest), responses}} - {rest, {type, payload}}, {:ok, p, s, responses} -> - s = Stream.consume_buffer(s, rest) + {rest, {type, payload}}, {:ok, protocol, stream, responses} -> + stream = Stream.consume_buffer(stream, rest) with {:ok, frame_struct} <- frame_for_type(type), {:ok, frame} <- Frame.decode(frame_struct, payload), - {:ok, p, s, new_responses} <- recv_frame(p, s, stream_handle, frame) do - {:cont, {:ok, p, s, new_responses ++ responses}} + {:ok, p, stream, new_responses} <- + recv_frame(protocol, stream, stream_handle, frame) do + {:cont, {:ok, p, stream, new_responses ++ responses}} else {:error, :not_found} -> # Unknown frame type — RFC 9114 §9: MUST be ignored. - {:cont, {:ok, p, s, responses}} + {:cont, {:ok, protocol, stream, responses}} - {:error, reason} -> - {:halt, {:error, reason}} + {:error, _reason} = error -> + {:halt, error} end end ) case result do - {:ok, %@for{} = p, s, responses} -> - p = %@for{p | streams: Map.put(p.streams, stream_handle, s)} - {:ok, p, Enum.reverse(responses)} + {:ok, %@for{} = protocol, stream, responses} -> + { + :ok, + %@for{protocol | streams: Map.put(protocol.streams, stream_handle, stream)}, + Enum.reverse(responses) + } - {:error, _} = error -> + {:error, _reason} = error -> error end end @@ -545,7 +506,7 @@ defmodule Ankh.Protocol.HTTP3 do # a `:headers` response event. defp recv_frame( %@for{} = protocol, - stream, + %Stream{} = stream, _stream_handle, %Headers{payload: %Headers.Payload{hbf: hbf}} ) do @@ -555,8 +516,12 @@ defmodule Ankh.Protocol.HTTP3 do case validate_incoming_headers(protocol, stream, headers) do :ok -> - stream = %{stream | recv_headers: true} - {:ok, protocol, stream, [{:headers, stream.reference, headers, false}]} + { + :ok, + protocol, + %{stream | recv_headers: true}, + [{:headers, stream.reference, headers, false}] + } {:error, reason} -> {:ok, protocol, stream, [{:error, stream.reference, reason, true}]} @@ -620,17 +585,15 @@ defmodule Ankh.Protocol.HTTP3 do %@for{mode: :server}, %Stream{recv_headers: false}, headers - ) do - Request.validate_headers(headers, false) - end + ), + do: Request.validate_headers(headers, false) defp validate_incoming_headers( %@for{mode: :client}, %Stream{recv_headers: false}, headers - ) do - Response.validate_headers(headers, false) - end + ), + do: Response.validate_headers(headers, false) defp validate_incoming_headers(_protocol, _stream, _headers), do: :ok end diff --git a/lib/ankh/protocol/http3/frame.ex b/lib/ankh/protocol/http3/frame.ex index 716e3a5..e6d5a66 100644 --- a/lib/ankh/protocol/http3/frame.ex +++ b/lib/ankh/protocol/http3/frame.ex @@ -67,10 +67,6 @@ defmodule Ankh.Protocol.HTTP3.Frame do alias Ankh.Protocol.HTTP3.Frame.Encodable - # --------------------------------------------------------------------------- - # Types - # --------------------------------------------------------------------------- - @typedoc "Frame type code (integer on the wire)." @type type :: non_neg_integer() @@ -83,10 +79,6 @@ defmodule Ankh.Protocol.HTTP3.Frame do @typedoc "The generic frame struct injected by `__using__/1`." @type t :: struct() - # --------------------------------------------------------------------------- - # __using__ macro - # --------------------------------------------------------------------------- - @doc """ Injects the frame struct into the calling module. @@ -156,10 +148,6 @@ defmodule Ankh.Protocol.HTTP3.Frame do end end - # --------------------------------------------------------------------------- - # stream/1 - # --------------------------------------------------------------------------- - @doc """ Returns a lazy stream of `{rest, frame_token}` pairs unfolded from `data`. @@ -210,10 +198,6 @@ defmodule Ankh.Protocol.HTTP3.Frame do end) end - # --------------------------------------------------------------------------- - # decode/2 - # --------------------------------------------------------------------------- - @doc """ Decodes `payload_binary` into the given frame struct by delegating to `Encodable.decode/2` on the struct's `:payload` field. @@ -229,16 +213,12 @@ defmodule Ankh.Protocol.HTTP3.Frame do # decoded.length == 5 """ @spec decode(t(), binary()) :: {:ok, t()} | {:error, any()} - def decode(frame, payload_binary) when is_binary(payload_binary) do - with {:ok, decoded_payload} <- Encodable.decode(frame.payload, payload_binary) do - {:ok, %{frame | length: byte_size(payload_binary), payload: decoded_payload}} + def decode(frame, payload) when is_binary(payload) do + with {:ok, decoded_payload} <- Encodable.decode(frame.payload, payload) do + {:ok, %{frame | length: byte_size(payload), payload: decoded_payload}} end end - # --------------------------------------------------------------------------- - # encode/1 - # --------------------------------------------------------------------------- - @doc """ Encodes a frame struct into its wire representation. @@ -258,21 +238,16 @@ defmodule Ankh.Protocol.HTTP3.Frame do @spec encode(t()) :: {:ok, t(), data()} | {:error, any()} def encode(%{type: type, payload: payload} = frame) do with {:ok, encoded_payload} <- Encodable.encode(payload) do - payload_bin = IO.iodata_to_binary(encoded_payload) - length = byte_size(payload_bin) + length = IO.iodata_length(encoded_payload) { :ok, %{frame | length: length}, - [encode_vli(type), encode_vli(length), payload_bin] + [encode_vli(type), encode_vli(length), encoded_payload] } end end - # --------------------------------------------------------------------------- - # VLI encode / decode - # --------------------------------------------------------------------------- - @doc """ Encodes a non-negative integer as a QUIC variable-length integer. @@ -328,14 +303,6 @@ defmodule Ankh.Protocol.HTTP3.Frame do def decode_vli(<<3::2, n::62, rest::binary>>), do: {:ok, n, rest} def decode_vli(_), do: {:error, :incomplete} - # --------------------------------------------------------------------------- - # Control stream preface - # --------------------------------------------------------------------------- - - @control_stream_type 0x00 - - # --------------------------------------------------------------------------- - @doc """ Returns the binary that MUST be written immediately after opening an HTTP/3 control stream (RFC 9114 §6.2.1). @@ -359,7 +326,8 @@ defmodule Ankh.Protocol.HTTP3.Frame do @spec control_stream_preface() :: binary() def control_stream_preface do << - @control_stream_type, + # Stream-type byte (0x00) - HTTP/3 control stream. + 0x00, # SETTINGS frame type (VLI 1-byte) 0x04, # SETTINGS payload length: 4 bytes (VLI 1-byte) diff --git a/lib/ankh/protocol/http3/stream.ex b/lib/ankh/protocol/http3/stream.ex index b5a598b..dd2fb6c 100644 --- a/lib/ankh/protocol/http3/stream.ex +++ b/lib/ankh/protocol/http3/stream.ex @@ -65,10 +65,6 @@ defmodule Ankh.Protocol.HTTP3.Stream do and decoding belong in `Ankh.Protocol.HTTP3.Frame`. """ - # --------------------------------------------------------------------------- - # Types - # --------------------------------------------------------------------------- - @typedoc """ HTTP/3 stream states, mirroring the QUIC stream half-close model. @@ -131,10 +127,6 @@ defmodule Ankh.Protocol.HTTP3.Stream do recv_headers: false, send_headers: false - # --------------------------------------------------------------------------- - # Construction - # --------------------------------------------------------------------------- - @doc """ Creates a new client-opened request stream for the given QUIC stream handle. @@ -201,10 +193,6 @@ defmodule Ankh.Protocol.HTTP3.Stream do } end - # --------------------------------------------------------------------------- - # Kind identification - # --------------------------------------------------------------------------- - @doc """ Resolves the kind of a unidirectional stream from its first stream-type byte. @@ -233,24 +221,14 @@ defmodule Ankh.Protocol.HTTP3.Stream do :unknown_unidirectional """ @spec identify_kind(t(), stream_type_byte :: non_neg_integer()) :: t() - def identify_kind(%__MODULE__{kind: :request} = stream, _stream_type_byte), do: stream - - def identify_kind(%__MODULE__{} = stream, stream_type_byte) do - kind = - case stream_type_byte do - 0x00 -> :control - 0x01 -> :push - 0x02 -> :qpack_encoder - 0x03 -> :qpack_decoder - _ -> :unknown_unidirectional - end - - %{stream | kind: kind} - end + def identify_kind(%__MODULE__{kind: :request} = stream, _type_byte), do: stream + def identify_kind(%__MODULE__{} = stream, 0x00), do: %{stream | kind: :control} + def identify_kind(%__MODULE__{} = stream, 0x01), do: %{stream | kind: :push} + def identify_kind(%__MODULE__{} = stream, 0x02), do: %{stream | kind: :qpack_encoder} + def identify_kind(%__MODULE__{} = stream, 0x03), do: %{stream | kind: :qpack_decoder} - # --------------------------------------------------------------------------- - # Buffer management - # --------------------------------------------------------------------------- + def identify_kind(%__MODULE__{} = stream, _type_byte), + do: %{stream | kind: :unknown_unidirectional} @doc """ Appends `data` to the stream's receive buffer. @@ -269,9 +247,8 @@ defmodule Ankh.Protocol.HTTP3.Stream do <<0x01, 0x05, "hello">> """ @spec append(t(), binary()) :: t() - def append(%__MODULE__{buffer: buffer} = stream, data) when is_binary(data) do - %{stream | buffer: buffer <> data} - end + def append(%__MODULE__{buffer: buffer} = stream, data) when is_binary(data), + do: %{stream | buffer: buffer <> data} @doc """ Replaces the stream buffer with `remainder`. @@ -289,13 +266,8 @@ defmodule Ankh.Protocol.HTTP3.Stream do <<0xFF>> """ @spec consume_buffer(t(), binary()) :: t() - def consume_buffer(%__MODULE__{} = stream, remainder) when is_binary(remainder) do - %{stream | buffer: remainder} - end - - # --------------------------------------------------------------------------- - # FIN / half-close transitions - # --------------------------------------------------------------------------- + def consume_buffer(%__MODULE__{} = stream, remainder) when is_binary(remainder), + do: %{stream | buffer: remainder} @doc """ Records that the remote peer has finished sending on this stream. @@ -359,10 +331,6 @@ defmodule Ankh.Protocol.HTTP3.Stream do def local_fin(%__MODULE__{} = stream), do: stream - # --------------------------------------------------------------------------- - # State predicates - # --------------------------------------------------------------------------- - @doc """ Returns `true` when the stream has reached the `:closed` state. diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index be13e12..275d2fc 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -88,7 +88,7 @@ defmodule Ankh.Transport.QUIC do def open_unidirectional_stream(%__MODULE__{connection: conn}, preface) when not is_nil(conn) do with {:ok, stream_id} <- :quic.open_unidirectional_stream(conn) do - :quic.send_data(conn, stream_id, IO.iodata_to_binary(preface), false) + :quic.send_data(conn, stream_id, preface, false) end end @@ -105,13 +105,6 @@ defmodule Ankh.Transport.QUIC do end defimpl Transport do - # Default ALPN for HTTP/3. - @default_alpn ["h3"] - - # --------------------------------------------------------------------------- - # new/2 - # --------------------------------------------------------------------------- - @doc """ Initialises the transport from an existing QUIC handle. @@ -125,17 +118,11 @@ defmodule Ankh.Transport.QUIC do known. """ def new(%@for{} = transport, {connection, stream}) - when is_pid(connection) and is_integer(stream) do - {:ok, %{transport | connection: connection, stream: stream}} - end + when is_pid(connection) and is_integer(stream), + do: {:ok, %{transport | connection: connection, stream: stream}} - def new(%@for{} = transport, connection) when is_pid(connection) do - {:ok, %{transport | connection: connection}} - end - - # --------------------------------------------------------------------------- - # connect/4 - # --------------------------------------------------------------------------- + def new(%@for{} = transport, connection) when is_pid(connection), + do: {:ok, %{transport | connection: connection}} @doc """ Opens a QUIC connection to the given `uri`. @@ -162,14 +149,14 @@ defmodule Ankh.Transport.QUIC do * `:password` — passphrase for an encrypted private key. """ def connect(%@for{} = transport, %URI{host: host, port: port}, timeout, options) do - alpn = Keyword.get(options, :alpn, @default_alpn) + alpn = Keyword.get(options, :alpn, ["h3"]) verify = case Keyword.get(options, :verify, false) do :verify_peer -> true :verify_none -> false - v when is_boolean(v) -> v - _ -> false + verify when is_boolean(verify) -> verify + _verify -> false end conn_opts = @@ -204,10 +191,6 @@ defmodule Ankh.Transport.QUIC do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) - # --------------------------------------------------------------------------- - # accept/2 - # --------------------------------------------------------------------------- - @doc """ Accepts an inbound QUIC stream on an already-established connection. @@ -230,32 +213,18 @@ defmodule Ankh.Transport.QUIC do end end - def accept(%@for{connection: nil}, _options) do - {:error, :no_connection} - end - - # --------------------------------------------------------------------------- - # send/2 - # --------------------------------------------------------------------------- + def accept(%@for{connection: nil}, _options), do: {:error, :no_connection} @doc """ Sends `data` over the QUIC stream synchronously. - Converts `data` to a binary via `IO.iodata_to_binary/1` and calls - `:quic.send_data/4` with `fin = false`. + Calls `:quic.send_data/4` with `fin = false`. """ def send(%@for{connection: conn, stream: stream}, data) - when not is_nil(conn) and not is_nil(stream) do - :quic.send_data(conn, stream, IO.iodata_to_binary(data), false) - end + when not is_nil(conn) and not is_nil(stream), + do: :quic.send_data(conn, stream, data, false) - def send(%@for{stream: nil}, _data) do - {:error, :no_stream} - end - - # --------------------------------------------------------------------------- - # recv/3 - # --------------------------------------------------------------------------- + def send(%@for{stream: nil}, _data), do: {:error, :no_stream} @doc """ Passively reads data from the QUIC stream by blocking on the next @@ -275,13 +244,7 @@ defmodule Ankh.Transport.QUIC do end end - def recv(%@for{stream: nil}, _size, _timeout) do - {:error, :no_stream} - end - - # --------------------------------------------------------------------------- - # close/1 - # --------------------------------------------------------------------------- + def recv(%@for{stream: nil}, _size, _timeout), do: {:error, :no_stream} @doc """ Closes the underlying QUIC connection. @@ -297,10 +260,6 @@ defmodule Ankh.Transport.QUIC do {:ok, %{transport | connection: nil, stream: nil}} end - # --------------------------------------------------------------------------- - # handle_msg/2 - # --------------------------------------------------------------------------- - @doc """ Handles asynchronous messages delivered by `erlang_quic`. @@ -330,9 +289,7 @@ defmodule Ankh.Transport.QUIC do %@for{connection: conn, stream: stream}, {:quic, conn, {:stream_data, stream, data, false}} ) - when is_binary(data) and not is_nil(stream) do - {:ok, data} - end + when is_binary(data) and not is_nil(stream), do: {:ok, data} # Final data chunk with FIN. def handle_msg( @@ -348,29 +305,19 @@ defmodule Ankh.Transport.QUIC do %@for{connection: conn, stream: stream}, {:quic, conn, {:stream_reset, stream, _error_code}} ) - when not is_nil(stream) do - {:error, :closed} - end + when not is_nil(stream), do: {:error, :closed} # Connection closed. def handle_msg(%@for{connection: conn}, {:quic, conn, {:closed, _reason}}) - when not is_nil(conn) do - {:error, :closed} - end + when not is_nil(conn), do: {:error, :closed} # Transport error. def handle_msg(%@for{connection: conn}, {:quic, conn, {:transport_error, _code, _reason}}) - when not is_nil(conn) do - {:error, :closed} - end + when not is_nil(conn), do: {:error, :closed} # Unrelated message — pass it through for the caller to handle. def handle_msg(_quic, msg), do: {:other, msg} - # --------------------------------------------------------------------------- - # negotiated_protocol/1 - # --------------------------------------------------------------------------- - @doc """ Returns the ALPN protocol stored during `connect/4`, or `"h3"` if this transport was initialised on the server side without going through `connect/4`. @@ -379,16 +326,8 @@ defmodule Ankh.Transport.QUIC do the negotiated ALPN is inferred from the first token in the requested list (connection succeeds only if the server agrees). """ - def negotiated_protocol(%@for{alpn: alpn}) when not is_nil(alpn) do - {:ok, alpn} - end - - def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn) do - {:ok, "h3"} - end - - def negotiated_protocol(%@for{connection: nil}) do - {:error, :protocol_not_negotiated} - end + def negotiated_protocol(%@for{alpn: alpn}) when not is_nil(alpn), do: {:ok, alpn} + def negotiated_protocol(%@for{connection: conn}) when not is_nil(conn), do: {:ok, "h3"} + def negotiated_protocol(%@for{connection: nil}), do: {:error, :protocol_not_negotiated} end end From e8cd7cab58214ce6b940433eaea33f3f0fdb9e31 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 21 May 2026 12:27:44 +0200 Subject: [PATCH 08/13] Remove unused import --- lib/ankh/transport/quic.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index 275d2fc..699cda4 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -35,8 +35,6 @@ defmodule Ankh.Transport.QUIC do Requires the `quic` optional dependency (erlang_quic) to be present and started. """ - require Logger - alias Ankh.Transport @typedoc "QUIC connection handle (pid returned by erlang_quic)" From fb56e5ced2b60004db66c6e816c5b85fdf79f277 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 21 May 2026 15:53:02 +0200 Subject: [PATCH 09/13] Cleanup --- lib/ankh/protocol/http3.ex | 12 -------- lib/ankh/transport/quic.ex | 9 ------ test/ankh/protocol/http3/frame_test.exs | 35 ----------------------- test/ankh/protocol/http3/request_test.exs | 28 ------------------ 4 files changed, 84 deletions(-) diff --git a/lib/ankh/protocol/http3.ex b/lib/ankh/protocol/http3.ex index c6ab302..059543e 100644 --- a/lib/ankh/protocol/http3.ex +++ b/lib/ankh/protocol/http3.ex @@ -177,10 +177,6 @@ defmodule Ankh.Protocol.HTTP3 do """ def error(%@for{transport: transport}), do: Transport.close(transport) - # ------------------------------------------------------------------------- - # request/2 - # ------------------------------------------------------------------------- - @doc """ Sends an HTTP/3 request on a new bidirectional QUIC stream. @@ -241,10 +237,6 @@ defmodule Ankh.Protocol.HTTP3 do end end - # ------------------------------------------------------------------------- - # respond/3 - # ------------------------------------------------------------------------- - @doc """ Sends an HTTP/3 response on the stream identified by `request_reference`. @@ -316,11 +308,7 @@ defmodule Ankh.Protocol.HTTP3 do * `{:data, ref, data, complete?}` — a DATA frame or end-of-stream signal * `{:error, ref, reason, true}` — a stream-level protocol error """ - # ------------------------------------------------------------------------- - # stream/2 — incoming erlang_quic message dispatcher - # ------------------------------------------------------------------------- - # Data on a stream (no FIN). def stream(%@for{} = protocol, {:quic, _conn, {:stream_data, stream_id, data, false}}) when is_binary(data) do handle_stream_data(protocol, stream_id, data) diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index 699cda4..81612f6 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -51,15 +51,6 @@ defmodule Ankh.Transport.QUIC do defstruct connection: nil, stream: nil, alpn: nil - # --------------------------------------------------------------------------- - # HTTP/3 stream-management helpers - # - # These functions are NOT part of the Ankh.Transport protocol. They are - # public module-level functions that the Ankh.Protocol.HTTP3 implementation - # uses to manage the multiple concurrent QUIC streams that HTTP/3 requires, - # without reaching into the :quic module directly. - # --------------------------------------------------------------------------- - @doc """ Opens a new bidirectional QUIC stream on the connection and returns a transport struct with that stream handle set. diff --git a/test/ankh/protocol/http3/frame_test.exs b/test/ankh/protocol/http3/frame_test.exs index 606911b..c1a172d 100644 --- a/test/ankh/protocol/http3/frame_test.exs +++ b/test/ankh/protocol/http3/frame_test.exs @@ -17,10 +17,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do Settings } - # --------------------------------------------------------------------------- - # VLI encoding - # --------------------------------------------------------------------------- - describe "encode_vli/1" do test "encodes zero as a single byte" do assert Frame.encode_vli(0) == <<0x00>> @@ -79,10 +75,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # VLI decoding - # --------------------------------------------------------------------------- - describe "decode_vli/1" do test "decodes a 1-byte integer" do assert {:ok, 7, <<>>} = Frame.decode_vli(<<7>>) @@ -136,10 +128,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # Frame encoding — struct-based API - # --------------------------------------------------------------------------- - describe "encode/1" do test "encodes a DATA frame" do frame = %Data{payload: %Data.Payload{data: "hello"}} @@ -233,17 +221,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # Streaming — Frame.stream/1 - # - # stream/1 uses Stream.unfold/2. After emitting {data, nil} for a partial - # frame the next accumulator state is `data` (unchanged), which would loop - # forever if the caller kept consuming. Therefore: - # • tests involving partial frames use Enum.take/2 to stop early. - # • tests where every byte belongs to a complete frame use Enum.to_list/1 - # (safe because the last next-state is <<>>, which terminates the stream). - # --------------------------------------------------------------------------- - describe "stream/1" do test "yields a complete DATA frame token" do {:ok, _, iodata} = Frame.encode(%Data{payload: %Data.Payload{data: "hi"}}) @@ -308,10 +285,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # Frame decoding — struct-based API - # --------------------------------------------------------------------------- - describe "decode/2" do test "decodes a DATA payload" do {:ok, decoded} = Frame.decode(%Data{}, "hello") @@ -374,10 +347,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # Control-stream preface - # --------------------------------------------------------------------------- - describe "control_stream_preface/0" do test "is exactly 7 bytes long" do assert byte_size(Frame.control_stream_preface()) == 7 @@ -414,10 +383,6 @@ defmodule Ankh.Protocol.HTTP3.FrameTest do end end - # --------------------------------------------------------------------------- - # Encode / decode round-trips (struct → wire → struct) - # --------------------------------------------------------------------------- - describe "encode/decode round-trips" do test "round-trips a DATA frame" do struct = %Data{payload: %Data.Payload{data: "hello"}} diff --git a/test/ankh/protocol/http3/request_test.exs b/test/ankh/protocol/http3/request_test.exs index 72e446c..878a982 100644 --- a/test/ankh/protocol/http3/request_test.exs +++ b/test/ankh/protocol/http3/request_test.exs @@ -7,10 +7,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do alias Ankh.HTTP alias Ankh.HTTP.Request - # ------------------------------------------------------------------------- - # Response helper - # ------------------------------------------------------------------------- - # Drives the receive loop until end_stream = true is signalled for `ref`, # returning {:ok, status, headers, body_binary} or {:error, reason}. defp collect_response(protocol, ref, timeout \\ 10_000) do @@ -100,10 +96,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do {protocol, ref} end - # ------------------------------------------------------------------------- - # Basic GET requests - # ------------------------------------------------------------------------- - describe "basic GET requests over HTTP/3" do test "GET / from cloudflare-quic.com returns HTTP 200" do protocol = connect_h3!("https://cloudflare-quic.com") @@ -183,10 +175,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do end end - # ------------------------------------------------------------------------- - # Request headers propagation - # ------------------------------------------------------------------------- - describe "request header propagation" do test "custom User-Agent header is sent (reflected in cf-ray)" do # Cloudflare always returns cf-ray regardless, but custom UA is sent — @@ -223,10 +211,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do end end - # ------------------------------------------------------------------------- - # Protocol negotiation - # ------------------------------------------------------------------------- - describe "protocol negotiation" do test "protocols: [:h3] connects with HTTP/3" do # The connection itself succeeding over QUIC is the assertion. @@ -271,10 +255,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do end end - # ------------------------------------------------------------------------- - # Multiple sequential requests on the same connection - # ------------------------------------------------------------------------- - describe "multiple sequential requests" do test "two sequential GET requests on the same H3 connection both succeed" do protocol = connect_h3!("https://cloudflare-quic.com") @@ -315,10 +295,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do end end - # ------------------------------------------------------------------------- - # QPACK header decoding correctness - # ------------------------------------------------------------------------- - describe "QPACK header decoding" do test "all response header names are lowercase strings" do protocol = connect_h3!("https://cloudflare-quic.com") @@ -376,10 +352,6 @@ defmodule Ankh.Protocol.HTTP3.RequestTest do end end - # ------------------------------------------------------------------------- - # Error handling - # ------------------------------------------------------------------------- - describe "connection error handling" do test "connecting to an unreachable host returns an error" do uri = URI.parse("https://this.host.does.not.exist.invalid") From 8b98bfad9e1c89eabd05c92728eaf37ee04e8c69 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Sat, 6 Jun 2026 11:32:50 +0200 Subject: [PATCH 10/13] Fix elixir 1.20.0 warnings --- lib/ankh/protocol/http3/frame.ex | 2 +- lib/ankh/protocol/http3/frame/cancel_push.ex | 1 - lib/ankh/protocol/http3/frame/data.ex | 1 - lib/ankh/protocol/http3/frame/go_away.ex | 1 - lib/ankh/protocol/http3/frame/headers.ex | 1 - lib/ankh/protocol/http3/frame/max_push_id.ex | 1 - lib/ankh/protocol/http3/frame/push_promise.ex | 2 -- lib/ankh/protocol/http3/frame/settings.ex | 2 -- 8 files changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/ankh/protocol/http3/frame.ex b/lib/ankh/protocol/http3/frame.ex index e6d5a66..27438fd 100644 --- a/lib/ankh/protocol/http3/frame.ex +++ b/lib/ankh/protocol/http3/frame.ex @@ -188,7 +188,7 @@ defmodule Ankh.Protocol.HTTP3.Frame do with {:ok, type, rest1} <- decode_vli(data), {:ok, length, rest2} <- decode_vli(rest1), true <- byte_size(rest2) >= length do - <> = rest2 + <> = rest2 {{rest3, {type, payload}}, rest3} else # Partial frame: emit one nil token then terminate the stream so diff --git a/lib/ankh/protocol/http3/frame/cancel_push.ex b/lib/ankh/protocol/http3/frame/cancel_push.ex index 398f115..bdc78a1 100644 --- a/lib/ankh/protocol/http3/frame/cancel_push.ex +++ b/lib/ankh/protocol/http3/frame/cancel_push.ex @@ -25,7 +25,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.CancelPush do def decode(_payload, _binary), do: {:error, :decode_error} def encode(%@for{push_id: id}), do: {:ok, Frame.encode_vli(id)} - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/data.ex b/lib/ankh/protocol/http3/frame/data.ex index 43a0631..0c019b9 100644 --- a/lib/ankh/protocol/http3/frame/data.ex +++ b/lib/ankh/protocol/http3/frame/data.ex @@ -14,7 +14,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.Data do def decode(_payload, _binary), do: {:error, :decode_error} def encode(%@for{data: data}), do: {:ok, data} - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/go_away.ex b/lib/ankh/protocol/http3/frame/go_away.ex index a3064c8..1aea76c 100644 --- a/lib/ankh/protocol/http3/frame/go_away.ex +++ b/lib/ankh/protocol/http3/frame/go_away.ex @@ -25,7 +25,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.GoAway do def decode(_payload, _binary), do: {:error, :decode_error} def encode(%@for{stream_id: id}), do: {:ok, Frame.encode_vli(id)} - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/headers.ex b/lib/ankh/protocol/http3/frame/headers.ex index 252ab6c..99c1f3c 100644 --- a/lib/ankh/protocol/http3/frame/headers.ex +++ b/lib/ankh/protocol/http3/frame/headers.ex @@ -14,7 +14,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.Headers do def decode(_payload, _binary), do: {:error, :decode_error} def encode(%@for{hbf: hbf}), do: {:ok, hbf} - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/max_push_id.ex b/lib/ankh/protocol/http3/frame/max_push_id.ex index 189d6c7..6765f2a 100644 --- a/lib/ankh/protocol/http3/frame/max_push_id.ex +++ b/lib/ankh/protocol/http3/frame/max_push_id.ex @@ -25,7 +25,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.MaxPushId do def decode(_payload, _binary), do: {:error, :decode_error} def encode(%@for{push_id: id}), do: {:ok, Frame.encode_vli(id)} - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/push_promise.ex b/lib/ankh/protocol/http3/frame/push_promise.ex index d659dd5..8c02283 100644 --- a/lib/ankh/protocol/http3/frame/push_promise.ex +++ b/lib/ankh/protocol/http3/frame/push_promise.ex @@ -26,8 +26,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.PushPromise do def encode(%@for{push_id: id, hbf: hbf}), do: {:ok, [Frame.encode_vli(id), hbf]} - - def encode(_payload), do: {:error, :encode_error} end end diff --git a/lib/ankh/protocol/http3/frame/settings.ex b/lib/ankh/protocol/http3/frame/settings.ex index c1def16..1eda21d 100644 --- a/lib/ankh/protocol/http3/frame/settings.ex +++ b/lib/ankh/protocol/http3/frame/settings.ex @@ -56,8 +56,6 @@ defmodule Ankh.Protocol.HTTP3.Frame.Settings do [Frame.encode_vli(id), Frame.encode_vli(value)] end)} end - - def encode(_payload), do: {:error, :encode_error} end end From c5b2cef98af6f7e34ab2937a5524f59b0dc4eb10 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Mon, 15 Jun 2026 10:49:29 +0200 Subject: [PATCH 11/13] Make quic non optional --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index fa591aa..034d491 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ defmodule Ankh.Mixfile do {:castore, "~> 1.0"}, {:hpax, "~> 1.0"}, {:plug, "~> 1.0"}, - {:quic, "~> 1.0", optional: true}, + {:quic, "~> 1.0"}, {:credo, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, "~> 0.40.0", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false} From 8fba4148fd2895f332615e41683eeb6357f7811a Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Mon, 15 Jun 2026 11:33:32 +0200 Subject: [PATCH 12/13] Remove dialyzer ignore file --- .dialyzer.ignore-warnings | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .dialyzer.ignore-warnings diff --git a/.dialyzer.ignore-warnings b/.dialyzer.ignore-warnings deleted file mode 100644 index fe51488..0000000 --- a/.dialyzer.ignore-warnings +++ /dev/null @@ -1 +0,0 @@ -[] From 15d50bf9717356946e01c9bd7364424cc64c5c7f Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Mon, 15 Jun 2026 11:37:04 +0200 Subject: [PATCH 13/13] Apply suggestion from @lucacorti --- lib/ankh/transport/quic.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/ankh/transport/quic.ex b/lib/ankh/transport/quic.ex index 81612f6..aa7d7f4 100644 --- a/lib/ankh/transport/quic.ex +++ b/lib/ankh/transport/quic.ex @@ -242,10 +242,7 @@ defmodule Ankh.Transport.QUIC do call; `:quic.close/1` tears down the whole connection. """ def close(%@for{connection: conn} = transport) do - if not is_nil(conn) do - :quic.close(conn) - end - + if not is_nil(conn), do: :quic.close(conn) {:ok, %{transport | connection: nil, stream: nil}} end