Skip to main content

src/roadrunner_http2_frame.erl

-module(roadrunner_http2_frame).
-moduledoc false.

%% HTTP/2 (RFC 9113 §4 + §6) frame codec — pure binary parsers and
%% encoders for all 10 frame types.
%%
%% This module is purely about wire-format syntax: it parses bytes into
%% typed terms and encodes typed terms back to bytes. It does **not**
%% enforce stream-state validity (RFC 9113 §5.1), connection-level
%% validity (e.g. SETTINGS arriving before the preface), or flow-control
%% arithmetic (§5.2). Those constraints are enforced by callers in
%% later phases.
%%
%% ## Frame types
%%
%% | Type | Hex | Module spec |
%% |------|-----|-------------|
%% | DATA          | 0x0 | RFC 9113 §6.1 |
%% | HEADERS       | 0x1 | RFC 9113 §6.2 |
%% | PRIORITY      | 0x2 | RFC 9113 §6.3 (deprecated, MUST accept) |
%% | RST_STREAM    | 0x3 | RFC 9113 §6.4 |
%% | SETTINGS      | 0x4 | RFC 9113 §6.5 |
%% | PUSH_PROMISE  | 0x5 | RFC 9113 §6.6 |
%% | PING          | 0x6 | RFC 9113 §6.7 |
%% | GOAWAY        | 0x7 | RFC 9113 §6.8 |
%% | WINDOW_UPDATE | 0x8 | RFC 9113 §6.9 |
%% | CONTINUATION  | 0x9 | RFC 9113 §6.10 |
%%
%% ## API
%%
%% `parse(Bin, MaxFrameSize)` is incremental: it returns
%% `{ok, frame(), Rest}` when a complete frame has been decoded,
%% `{more, Need}` when more bytes are required, or `{error, Reason}`
%% on syntactic failure. `encode(frame())` returns iodata.
%%
%% Frame flags are kept as the raw 8-bit byte in the parsed shape —
%% higher layers interpret them per-type. Reserved bits in flag bytes
%% MUST be ignored on receive (§4.1) and MUST be sent as 0; the
%% encoder zeros any unknown bits.
%%
%% ## Padding
%%
%% DATA, HEADERS, and PUSH_PROMISE frames support optional padding
%% (PADDED flag, 0x8 — RFC 9113 §6.1 / §6.2 / §6.6). When present the
%% first byte of the payload is the pad length, followed by the
%% fragment, followed by `pad-length` bytes of padding. Pad length
%% larger than the available frame payload is `PROTOCOL_ERROR`.
%%
%% ## Stream id 0 / non-zero rules
%%
%% Per RFC 9113 §6, certain frame types MUST be on stream 0
%% (SETTINGS, PING, GOAWAY) and others MUST be on a non-zero stream
%% (DATA, HEADERS, PRIORITY, RST_STREAM, PUSH_PROMISE,
%% WINDOW_UPDATE-on-stream, CONTINUATION). The parser enforces this
%% at parse time with a `stream_id_violation` error so callers don't
%% have to re-validate.
%%
%% ## MAX_FRAME_SIZE
%%
%% Per RFC 9113 §6.5.2 SETTINGS_MAX_FRAME_SIZE governs the largest
%% frame payload the receiver will accept. The codec takes the limit
%% as an explicit argument so it can grow as the client advertises a
%% higher value. Frames whose `length` exceeds the limit fail with
%% `frame_size_error`.

-export([parse/2, encode/1]).

-export_type([
    frame/0,
    frame_type/0,
    error_code/0,
    parse_result/0,
    parse_error/0,
    priority/0,
    settings_param/0
]).

%% RFC 9113 §7 error codes — used by RST_STREAM and GOAWAY payloads.
%% Listed by name; the encoded form is `code(Atom)`.
-type error_code() ::
    no_error
    | protocol_error
    | internal_error
    | flow_control_error
    | settings_timeout
    | stream_closed
    | frame_size_error
    | refused_stream
    | cancel
    | compression_error
    | connect_error
    | enhance_your_calm
    | inadequate_security
    | http_1_1_required
    | non_neg_integer().

-type frame_type() ::
    data
    | headers
    | priority
    | rst_stream
    | settings
    | push_promise
    | ping
    | goaway
    | window_update
    | continuation.

-type stream_id() :: non_neg_integer().
-type flags() :: 0..255.

-type priority() :: #{
    exclusive := boolean(),
    stream_dependency := stream_id(),
    weight := 0..255
}.

-type settings_param() :: {ParamId :: non_neg_integer(), Value :: non_neg_integer()}.

-type frame() ::
    {data, stream_id(), flags(), Payload :: iodata()}
    | {headers, stream_id(), flags(), Priority :: priority() | undefined, HeaderBlock :: iodata()}
    | {priority, stream_id(), priority()}
    | {rst_stream, stream_id(), error_code()}
    | {settings, flags(), [settings_param()]}
    | {push_promise, stream_id(), flags(), PromisedStreamId :: stream_id(), HeaderBlock :: iodata()}
    | {ping, flags(), OpaqueData :: <<_:64>>}
    | {goaway, LastStreamId :: stream_id(), error_code(), DebugData :: iodata()}
    | {window_update, stream_id(), Increment :: 1..16#7FFFFFFF}
    | {continuation, stream_id(), flags(), HeaderBlock :: iodata()}
    %% Unknown frame types — RFC 9113 §4.1: receivers MUST ignore.
    %% Surfaced (rather than dropped at parse time) so the conn can
    %% enforce the §6.10 "no non-CONTINUATION between HEADERS and
    %% CONTINUATION" rule even for unknown frame types.
    | {unknown, Type :: non_neg_integer(), stream_id()}.

-type parse_error() ::
    frame_size_error
    | protocol_error
    | stream_id_violation
    | bad_padding
    | bad_priority_payload
    | bad_rst_stream_payload
    | bad_settings_payload
    | bad_push_promise_payload
    | bad_ping_payload
    | bad_goaway_payload
    | bad_window_update_payload
    | window_update_zero_increment.

-type parse_result() ::
    {ok, frame(), Rest :: binary()}
    | {more, Need :: pos_integer()}
    | {error, parse_error()}.

%% RFC 9113 §11.2 IANA-registered frame types.
-define(TYPE_DATA, 0).
-define(TYPE_HEADERS, 1).
-define(TYPE_PRIORITY, 2).
-define(TYPE_RST_STREAM, 3).
-define(TYPE_SETTINGS, 4).
-define(TYPE_PUSH_PROMISE, 5).
-define(TYPE_PING, 6).
-define(TYPE_GOAWAY, 7).
-define(TYPE_WINDOW_UPDATE, 8).
-define(TYPE_CONTINUATION, 9).

%% Common flag bits used across multiple frame types.
-define(FLAG_ACK, 16#01).
-define(FLAG_PADDED, 16#08).
-define(FLAG_PRIORITY, 16#20).

%% =============================================================================
%% parse/2
%% =============================================================================

-doc """
Decode the next frame from `Bin`, rejecting any frame whose payload
length exceeds `MaxFrameSize`.

Returns:
- `{ok, Frame, Rest}` — `Frame` is fully decoded, `Rest` is the
  remaining buffer that follows it.
- `{more, NumBytes}` — at least `NumBytes` more bytes are needed
  before a frame can be decoded. Caller should `recv` more.
- `{error, Reason}` — frame is malformed in a way that maps to an
  RFC 9113 connection or stream error code. Caller decides whether
  to RST_STREAM or GOAWAY.
""".
-spec parse(binary(), pos_integer()) -> parse_result().
parse(<<Length:24, _/binary>>, MaxFrameSize) when Length > MaxFrameSize ->
    {error, frame_size_error};
parse(<<Length:24, _Type:8, _Flags:8, _R:1, _StreamId:31, Rest/binary>>, _MaxFrameSize) when
    byte_size(Rest) < Length
->
    {more, Length - byte_size(Rest)};
parse(<<Length:24, Type:8, Flags:8, _R:1, StreamId:31, Body/binary>>, _MaxFrameSize) ->
    <<Payload:Length/binary, Rest/binary>> = Body,
    case decode(Type, Flags, StreamId, Payload) of
        {ok, Frame} -> {ok, Frame, Rest};
        ignore -> {ok, {unknown, Type, StreamId}, Rest};
        {error, _} = E -> E
    end;
parse(Bin, _MaxFrameSize) when byte_size(Bin) < 9 ->
    {more, 9 - byte_size(Bin)}.

%% =============================================================================
%% Per-type decode
%% =============================================================================

-spec decode(non_neg_integer(), flags(), stream_id(), binary()) ->
    {ok, frame()} | ignore | {error, parse_error()}.
decode(?TYPE_DATA, _Flags, 0, _Payload) ->
    %% RFC 9113 §6.1: DATA on stream 0 is a connection error.
    {error, stream_id_violation};
decode(?TYPE_DATA, Flags, StreamId, Payload) ->
    case strip_padding(Flags, Payload) of
        {ok, Body} -> {ok, {data, StreamId, Flags, Body}};
        {error, _} = E -> E
    end;
decode(?TYPE_HEADERS, _Flags, 0, _Payload) ->
    %% RFC 9113 §6.2: HEADERS on stream 0 is a connection error.
    {error, stream_id_violation};
decode(?TYPE_HEADERS, Flags, StreamId, Payload) ->
    case strip_padding(Flags, Payload) of
        {ok, Unpadded} ->
            case has_flag(Flags, ?FLAG_PRIORITY) of
                false ->
                    {ok, {headers, StreamId, Flags, undefined, Unpadded}};
                true ->
                    decode_headers_with_priority(StreamId, Flags, Unpadded)
            end;
        {error, _} = E ->
            E
    end;
decode(?TYPE_PRIORITY, _Flags, 0, _Payload) ->
    %% §6.3: PRIORITY on stream 0 is a connection error.
    {error, stream_id_violation};
decode(?TYPE_PRIORITY, _Flags, StreamId, <<E:1, Dep:31, Weight:8>>) ->
    {ok,
        {priority, StreamId, #{
            exclusive => E =:= 1,
            stream_dependency => Dep,
            weight => Weight
        }}};
decode(?TYPE_PRIORITY, _Flags, _StreamId, _Payload) ->
    {error, bad_priority_payload};
decode(?TYPE_RST_STREAM, _Flags, 0, _Payload) ->
    %% §6.4: RST_STREAM on stream 0 is a connection error.
    {error, stream_id_violation};
decode(?TYPE_RST_STREAM, _Flags, StreamId, <<Code:32>>) ->
    {ok, {rst_stream, StreamId, code_atom(Code)}};
decode(?TYPE_RST_STREAM, _Flags, _StreamId, _Payload) ->
    {error, bad_rst_stream_payload};
decode(?TYPE_SETTINGS, _Flags, NonZero, _Payload) when NonZero =/= 0 ->
    %% §6.5: SETTINGS MUST be on stream 0.
    {error, stream_id_violation};
decode(?TYPE_SETTINGS, Flags, 0, Payload) ->
    %% §4.1: undefined flags MUST be ignored — mask Flags down to
    %% the only defined SETTINGS flag (ACK) so handle_frame/2's
    %% literal `0` / `1` patterns match cleanly even when the peer
    %% sets stray bits.
    case has_flag(Flags, ?FLAG_ACK) of
        true when byte_size(Payload) =/= 0 ->
            %% ACK SETTINGS MUST be empty (§6.5).
            {error, bad_settings_payload};
        true ->
            {ok, {settings, ?FLAG_ACK, []}};
        false ->
            case decode_settings_params(Payload) of
                {ok, Params} -> {ok, {settings, 0, Params}};
                {error, _} = E -> E
            end
    end;
decode(?TYPE_PUSH_PROMISE, _Flags, 0, _Payload) ->
    %% §6.6: PUSH_PROMISE MUST be on a non-zero stream.
    {error, stream_id_violation};
decode(?TYPE_PUSH_PROMISE, Flags, StreamId, Payload) ->
    case strip_padding(Flags, Payload) of
        {ok, <<_R:1, PromisedId:31, HeaderBlock/binary>>} ->
            {ok, {push_promise, StreamId, Flags, PromisedId, HeaderBlock}};
        {ok, _} ->
            {error, bad_push_promise_payload};
        {error, _} = E ->
            E
    end;
decode(?TYPE_PING, _Flags, NonZero, _Payload) when NonZero =/= 0 ->
    %% §6.7: PING MUST be on stream 0.
    {error, stream_id_violation};
decode(?TYPE_PING, Flags, 0, <<OpaqueData:8/binary>>) ->
    %% Mask Flags to the only defined PING flag (ACK) so undefined
    %% bits don't bypass `handle_frame/2`'s literal `0` / `1`
    %% patterns (RFC 9113 §4.1).
    {ok, {ping, Flags band ?FLAG_ACK, OpaqueData}};
decode(?TYPE_PING, _Flags, 0, _Payload) ->
    {error, bad_ping_payload};
decode(?TYPE_GOAWAY, _Flags, NonZero, _Payload) when NonZero =/= 0 ->
    %% §6.8: GOAWAY MUST be on stream 0.
    {error, stream_id_violation};
decode(?TYPE_GOAWAY, _Flags, 0, <<_R:1, LastStreamId:31, ErrorCode:32, Debug/binary>>) ->
    {ok, {goaway, LastStreamId, code_atom(ErrorCode), Debug}};
decode(?TYPE_GOAWAY, _Flags, 0, _Payload) ->
    {error, bad_goaway_payload};
decode(?TYPE_WINDOW_UPDATE, _Flags, _StreamId, <<_R:1, 0:31>>) ->
    %% §6.9.1: a flow-control window increment of 0 is a
    %% PROTOCOL_ERROR; the value of `_StreamId` decides whether the
    %% caller treats it as a stream or connection error.
    {error, window_update_zero_increment};
decode(?TYPE_WINDOW_UPDATE, _Flags, StreamId, <<_R:1, Increment:31>>) ->
    {ok, {window_update, StreamId, Increment}};
decode(?TYPE_WINDOW_UPDATE, _Flags, _StreamId, _Payload) ->
    {error, bad_window_update_payload};
decode(?TYPE_CONTINUATION, _Flags, 0, _Payload) ->
    %% §6.10: CONTINUATION MUST be on a non-zero stream.
    {error, stream_id_violation};
decode(?TYPE_CONTINUATION, Flags, StreamId, Payload) ->
    {ok, {continuation, StreamId, Flags, Payload}};
decode(_UnknownType, _Flags, _StreamId, _Payload) ->
    %% RFC 9113 §4.1 last paragraph + §5.5: unknown frame types
    %% MUST be ignored. `parse/2` recurses on `ignore` to advance
    %% past the silently-discarded frame.
    ignore.

%% Strip the optional pad-length byte + trailing pad bytes per
%% §6.1 / §6.2 / §6.6. When PADDED is not set, the payload is
%% returned unchanged.
-spec strip_padding(flags(), binary()) -> {ok, binary()} | {error, bad_padding}.
strip_padding(Flags, Payload) ->
    case has_flag(Flags, ?FLAG_PADDED) of
        false ->
            {ok, Payload};
        true ->
            case Payload of
                <<PadLen:8, Rest/binary>> when PadLen =< byte_size(Rest) ->
                    Take = byte_size(Rest) - PadLen,
                    <<Inner:Take/binary, _Pad:PadLen/binary>> = Rest,
                    {ok, Inner};
                _ ->
                    {error, bad_padding}
            end
    end.

%% HEADERS with the PRIORITY flag prepends a 5-byte priority block
%% (RFC 9113 §6.2).
-spec decode_headers_with_priority(stream_id(), flags(), binary()) ->
    {ok, frame()} | {error, parse_error()}.
decode_headers_with_priority(
    StreamId, Flags, <<E:1, Dep:31, Weight:8, HeaderBlock/binary>>
) ->
    Priority = #{
        exclusive => E =:= 1,
        stream_dependency => Dep,
        weight => Weight
    },
    {ok, {headers, StreamId, Flags, Priority, HeaderBlock}};
decode_headers_with_priority(_StreamId, _Flags, _Payload) ->
    {error, protocol_error}.

%% Walk a SETTINGS payload (sequence of 6-byte parameter records).
%% RFC 9113 §6.5: payload length MUST be a multiple of 6; the
%% caller already ensured Length matched the framed body.
%% Body recursion — cons each parameter onto the result on the way
%% back out so we never call `lists:reverse/1`.
-spec decode_settings_params(binary()) ->
    {ok, [settings_param()]} | {error, bad_settings_payload}.
decode_settings_params(<<>>) ->
    {ok, []};
decode_settings_params(<<Id:16, Value:32, Rest/binary>>) ->
    case decode_settings_params(Rest) of
        {ok, Tail} -> {ok, [{Id, Value} | Tail]};
        {error, _} = E -> E
    end;
decode_settings_params(_Bin) ->
    {error, bad_settings_payload}.

-spec has_flag(flags(), flags()) -> boolean().
has_flag(Flags, Mask) ->
    (Flags band Mask) =/= 0.

%% =============================================================================
%% encode/1
%% =============================================================================

-doc """
Encode a `frame()` term back to its 9-byte header + payload wire form.
Returns `iodata()` so callers can chain through `gen_tcp:send/2`
without an intermediate flatten.
""".
-spec encode(frame()) -> iodata().
encode({data, StreamId, Flags, Payload}) ->
    %% Padding is opt-in on encode: callers can include it themselves
    %% by passing the pad-length-prefixed body and the PADDED flag.
    %% This module does not auto-pad.
    frame_io(?TYPE_DATA, Flags, StreamId, Payload);
encode({headers, StreamId, Flags, undefined, HeaderBlock}) ->
    frame_io(?TYPE_HEADERS, Flags band (bnot ?FLAG_PRIORITY), StreamId, HeaderBlock);
encode({headers, StreamId, Flags, #{} = Priority, HeaderBlock}) ->
    Block = [encode_priority(Priority), HeaderBlock],
    frame_io(?TYPE_HEADERS, Flags bor ?FLAG_PRIORITY, StreamId, Block);
encode({priority, StreamId, #{} = Priority}) ->
    frame_io(?TYPE_PRIORITY, 0, StreamId, encode_priority(Priority));
encode({rst_stream, StreamId, ErrorCode}) ->
    frame_io(?TYPE_RST_STREAM, 0, StreamId, <<(code_int(ErrorCode)):32>>);
encode({settings, Flags, Params}) ->
    Body = [<<Id:16, Value:32>> || {Id, Value} <- Params],
    frame_io(?TYPE_SETTINGS, Flags, 0, Body);
encode({push_promise, StreamId, Flags, PromisedId, HeaderBlock}) ->
    Body = [<<0:1, PromisedId:31>>, HeaderBlock],
    frame_io(?TYPE_PUSH_PROMISE, Flags, StreamId, Body);
encode({ping, Flags, <<OpaqueData:8/binary>>}) ->
    frame_io(?TYPE_PING, Flags, 0, OpaqueData);
encode({goaway, LastStreamId, ErrorCode, DebugData}) ->
    Body = [<<0:1, LastStreamId:31, (code_int(ErrorCode)):32>>, DebugData],
    frame_io(?TYPE_GOAWAY, 0, 0, Body);
encode({window_update, StreamId, Increment}) ->
    frame_io(?TYPE_WINDOW_UPDATE, 0, StreamId, <<0:1, Increment:31>>);
encode({continuation, StreamId, Flags, HeaderBlock}) ->
    frame_io(?TYPE_CONTINUATION, Flags, StreamId, HeaderBlock).

-spec encode_priority(priority()) -> binary().
encode_priority(#{exclusive := Excl, stream_dependency := Dep, weight := Weight}) ->
    E =
        case Excl of
            true -> 1;
            false -> 0
        end,
    <<E:1, Dep:31, Weight:8>>.

%% Build the 9-byte header + payload as iodata. `Body` is iodata so
%% callers can chain pre-built fragments without copying.
-spec frame_io(non_neg_integer(), flags(), stream_id(), iodata()) -> iodata().
frame_io(Type, Flags, StreamId, Body) ->
    Length = iolist_size(Body),
    [<<Length:24, Type, Flags, 0:1, StreamId:31>>, Body].

%% =============================================================================
%% Error code mapping (RFC 9113 §7)
%% =============================================================================

-spec code_atom(non_neg_integer()) -> error_code().
code_atom(16#0) -> no_error;
code_atom(16#1) -> protocol_error;
code_atom(16#2) -> internal_error;
code_atom(16#3) -> flow_control_error;
code_atom(16#4) -> settings_timeout;
code_atom(16#5) -> stream_closed;
code_atom(16#6) -> frame_size_error;
code_atom(16#7) -> refused_stream;
code_atom(16#8) -> cancel;
code_atom(16#9) -> compression_error;
code_atom(16#A) -> connect_error;
code_atom(16#B) -> enhance_your_calm;
code_atom(16#C) -> inadequate_security;
code_atom(16#D) -> http_1_1_required;
code_atom(N) -> N.

-spec code_int(error_code()) -> non_neg_integer().
code_int(no_error) -> 16#0;
code_int(protocol_error) -> 16#1;
code_int(internal_error) -> 16#2;
code_int(flow_control_error) -> 16#3;
code_int(settings_timeout) -> 16#4;
code_int(stream_closed) -> 16#5;
code_int(frame_size_error) -> 16#6;
code_int(refused_stream) -> 16#7;
code_int(cancel) -> 16#8;
code_int(compression_error) -> 16#9;
code_int(connect_error) -> 16#A;
code_int(enhance_your_calm) -> 16#B;
code_int(inadequate_security) -> 16#C;
code_int(http_1_1_required) -> 16#D;
code_int(N) when is_integer(N) -> N.