Skip to main content

src/nquic_protocol_cid.erl

-module(nquic_protocol_cid).
-moduledoc """
Connection ID management for the QUIC protocol state.

Pure functions over `#conn_state{}` that handle peer CID issuance
(NEW_CONNECTION_ID), retirement (RETIRE_CONNECTION_ID), local CID
rotation, and DCID switching for path migration. Extracted from
`nquic_protocol` as part of REVIEW_PLAN.md Phase 4.4.

Side effects are limited to `nquic_listener:dispatch_register/3` and
`nquic_listener:dispatch_unregister/2` for keeping the dispatch table
in sync when the connection has one set; everything else is functional
state manipulation.
""".

-include("nquic_conn.hrl").
-include("nquic_frame.hrl").
-include("nquic_transport.hrl").
-export([
    find_cid_seq/2,
    handle_new_connection_id/5,
    handle_retire_connection_id/2,
    issue_new_connection_id/1,
    issue_spare_cids/1,
    retire_peer_cids/2,
    rotate_dcid/1
]).

%%%-----------------------------------------------------------------------------
%% PUBLIC API
%%%-----------------------------------------------------------------------------
-spec find_cid_seq(nquic:connection_id(), #{non_neg_integer() => map()}) ->
    non_neg_integer() | undefined.
find_cid_seq(CID, PeerCids) ->
    maps:fold(
        fun(Seq, #{cid := C}, Acc) ->
            case C of
                CID -> Seq;
                _ -> Acc
            end
        end,
        undefined,
        PeerCids
    ).

-spec handle_new_connection_id(
    non_neg_integer(), non_neg_integer(), nquic:connection_id(), binary(), nquic_protocol:state()
) ->
    {ok, nquic_protocol:state()}.
handle_new_connection_id(SeqNum, RetirePriorTo, CID, Token, State) ->
    #conn_state{path = Path0} = State,
    #conn_path_mgmt{
        peer_cids = PeerCids,
        peer_retire_prior_to = CurrentRetirePrior
    } = Path0,
    NewRetirePrior = max(CurrentRetirePrior, RetirePriorTo),
    PeerCids1 =
        case SeqNum >= NewRetirePrior of
            true -> PeerCids#{SeqNum => #{cid => CID, token => Token}};
            false -> PeerCids
        end,
    {ToRetire, PeerCids2} = retire_peer_cids(NewRetirePrior, PeerCids1),
    NewPath = Path0#conn_path_mgmt{
        peer_cids = PeerCids2,
        peer_retire_prior_to = NewRetirePrior
    },
    State1 = State#conn_state{path = NewPath},
    State2 = send_retire_frames(ToRetire, State1),
    {ok, State2}.

-spec handle_retire_connection_id(non_neg_integer(), nquic_protocol:state()) ->
    {ok, nquic_protocol:state()}.
handle_retire_connection_id(SeqNum, State) ->
    #conn_state{path = Path0, dispatch_table = Table} = State,
    LocalCids = Path0#conn_path_mgmt.local_cids,
    case maps:get(SeqNum, LocalCids, undefined) of
        undefined ->
            {ok, State};
        CID ->
            case Table of
                undefined -> ok;
                _ -> nquic_listener:dispatch_unregister(Table, CID)
            end,
            NewLocalCids = maps:remove(SeqNum, LocalCids),
            NewPath = Path0#conn_path_mgmt{local_cids = NewLocalCids},
            State1 = State#conn_state{path = NewPath},
            issue_new_connection_id(State1)
    end.

-spec retire_peer_cids(non_neg_integer(), #{non_neg_integer() => map()}) ->
    {[non_neg_integer()], #{non_neg_integer() => map()}}.
retire_peer_cids(RetirePriorTo, PeerCids) ->
    maps:fold(
        fun(Seq, _Entry, {Retired, Remaining}) ->
            case Seq < RetirePriorTo of
                true -> {[Seq | Retired], maps:remove(Seq, Remaining)};
                false -> {Retired, Remaining}
            end
        end,
        {[], PeerCids},
        PeerCids
    ).

%%%-----------------------------------------------------------------------------
%% INTERNAL
%%%-----------------------------------------------------------------------------
-spec issue_n_cids(non_neg_integer(), nquic_protocol:state()) -> {ok, nquic_protocol:state()}.
issue_n_cids(0, State) ->
    {ok, State};
issue_n_cids(N, State) ->
    case issue_new_connection_id(State) of
        {ok, State1} -> issue_n_cids(N - 1, State1)
    end.

-spec issue_new_connection_id(nquic_protocol:state()) -> {ok, nquic_protocol:state()}.
issue_new_connection_id(State) ->
    #conn_state{path = Path0, dispatch_table = Table, crypto = Crypto} = State,
    #conn_path_mgmt{local_cids = LocalCids, local_cid_seq = NextSeq} = Path0,
    NewCID = nquic_keys:generate_connection_id(8),
    ResetToken =
        case Crypto#conn_crypto.static_key of
            undefined -> crypto:strong_rand_bytes(16);
            SK -> nquic_stateless_reset:generate_token(SK, NewCID)
        end,
    case Table of
        undefined -> ok;
        _ -> nquic_listener:dispatch_register(Table, NewCID, self())
    end,
    Frame = #new_connection_id{
        seq_num = NextSeq,
        retire_prior_to = 0,
        cid = NewCID,
        stateless_reset_token = ResetToken
    },
    NewLocalCids = LocalCids#{NextSeq => NewCID},
    NewPath = Path0#conn_path_mgmt{
        local_cids = NewLocalCids,
        local_cid_seq = NextSeq + 1
    },
    State1 = State#conn_state{path = NewPath},
    nquic_protocol_send_queues:queue_app_frame(Frame, State1).

-doc """
Issue spare NEW_CONNECTION_ID frames up to the peer's
`active_connection_id_limit`.
Both endpoints account the SCID delivered in transport parameters as a
single local CID (sequence 0); peers tolerate up to `limit` concurrent
CIDs (RFC 9000 ยง5.1.1). We top up the gap so a future migration has
spare CIDs on both sides; without this, server-initiated migration
(per-conn FDs) cannot rotate DCID and the path validation refuses to
flip.
No-op when remote transport parameters have not been processed yet:
the limit is unknown, and the existing reactive `issue_new_connection_id`
path picks up the slack on demand.
""".
-spec issue_spare_cids(nquic_protocol:state()) -> {ok, nquic_protocol:state()}.
issue_spare_cids(#conn_state{remote_params = undefined} = State) ->
    {ok, State};
issue_spare_cids(#conn_state{remote_params = RP, path = Path} = State) ->
    Limit = RP#transport_params.active_connection_id_limit,
    Current = map_size(Path#conn_path_mgmt.local_cids),
    issue_n_cids(max(0, Limit - Current), State).

-spec rotate_dcid(nquic_protocol:state()) ->
    {ok, nquic_protocol:state()} | {error, no_available_cids}.
rotate_dcid(#conn_state{path = Path0, dcid = CurrentDCID} = State) ->
    PeerCids = Path0#conn_path_mgmt.peer_cids,
    Available = maps:filter(
        fun(_Seq, #{cid := CID}) -> CID =/= CurrentDCID end,
        PeerCids
    ),
    case map_size(Available) of
        0 ->
            {error, no_available_cids};
        _ ->
            MinSeq = lists:min(maps:keys(Available)),
            #{cid := NewDCID} = maps:get(MinSeq, Available),
            OldSeq = find_cid_seq(CurrentDCID, PeerCids),
            State1 = State#conn_state{dcid = NewDCID},
            State2 =
                case OldSeq of
                    undefined -> State1;
                    Seq -> send_retire_frames([Seq], State1)
                end,
            {ok, State2}
    end.

-spec send_retire_frames([non_neg_integer()], nquic_protocol:state()) -> nquic_protocol:state().
send_retire_frames([], State) ->
    State;
send_retire_frames([Seq | Rest], State) ->
    Frame = #retire_connection_id{seq_num = Seq},
    {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
    send_retire_frames(Rest, State1).