%% @doc Source-route header codec (Part 6 §11). %% %% Wire layout (big-endian throughout): %% %%
%%   0       1       2       3
%%   +-------+-------+-------+-------+
%%   | ver   | total | curr  | dead-
%%   +-------+-------+-------+-------+
%%   ...    deadline (8 bytes)    ...
%%   +-------+-------+-------+-------+
%%   ...    path_hash (16 bytes)  ...
%%   +-------+-------+-------+-------+
%%   ...    hops[0..total_hops-1] (each 16 bytes — NodeId prefix)
%% 
%% %% %% %% Fixed overhead is 27 bytes (`1 + 1 + 1 + 8 + 16'). A path of %% `N' hops occupies `27 + 16*N' bytes; maximum (`N = 8') is %% 155 bytes. %% %% Reference: plans/PLAN_MACULA_V2_PART6_PROTOCOL.md §11; %% plans/PLAN_MACULA_V2_PART3_DISCOVERY.md §6.6; %% plans/PLAN_PHASE_4_BREAKDOWN.md Session 4.2. -module(macula_source_route). -export([ new/2, new/3, encode/1, decode/1, verify/1, advance/1, deadline/1, version/1, total_hops/1, current_hop/1, hops/1, path_hash/1, current_hop_id/1, next_hop_id/1, is_complete/1, is_final_hop/1, truncate_hop/1 ]). -export_type([header/0, hop_id/0, decode_error/0]). -define(VERSION, 1). -define(MAX_HOPS, 8). -define(HOP_BYTES, 16). -define(HASH_BYTES, 16). -define(FIXED_OVERHEAD, 27). -type hop_id() :: <<_:128>>. -type header() :: #{ version := non_neg_integer(), total_hops := pos_integer(), current_hop := non_neg_integer(), deadline := non_neg_integer(), path_hash := <<_:128>>, hops := [hop_id(), ...] }. -type decode_error() :: bad_header | bad_version | bad_total_hops | bad_current_hop | path_hash_mismatch | truncated. %%===================================================================== %% Construction %%===================================================================== %% @doc Build a header from a list of hops + an absolute deadline %% (Unix ms). Each hop may be an already-truncated 16-byte ID or a %% full 32-byte NodeId; full IDs are truncated to their first 16 %% bytes per the wire format. -spec new([hop_id() | macula_identity:pubkey()], non_neg_integer()) -> header(). new(Hops, DeadlineMs) -> new(Hops, DeadlineMs, 0). -spec new([hop_id() | macula_identity:pubkey()], non_neg_integer(), non_neg_integer()) -> header(). new(Hops, DeadlineMs, CurrentHop) when is_list(Hops), is_integer(DeadlineMs), DeadlineMs >= 0, is_integer(CurrentHop), CurrentHop >= 0 -> Truncated = [truncate_hop(H) || H <- Hops], Total = length(Truncated), valid_total(Total), valid_current(CurrentHop, Total), #{ version => ?VERSION, total_hops => Total, current_hop => CurrentHop, deadline => DeadlineMs, path_hash => compute_hash(Truncated), hops => Truncated }. %%===================================================================== %% Wire codec %%===================================================================== -spec encode(header()) -> binary(). encode(#{version := V, total_hops := T, current_hop := C, deadline := D, path_hash := H, hops := Hops}) when is_integer(V), V >= 0, V =< 255, is_integer(T), T >= 1, T =< ?MAX_HOPS, is_integer(C), C >= 0, C =< T, is_integer(D), D >= 0, is_binary(H), byte_size(H) =:= ?HASH_BYTES, length(Hops) =:= T -> HopsBin = << <> || Hop <- Hops >>, <>. %% @doc Decode a wire-format header. Verifies the path_hash so a %% caller can trust the structure was not tampered in flight. -spec decode(binary()) -> {ok, header()} | {error, decode_error()}. decode(Bin) when is_binary(Bin), byte_size(Bin) >= ?FIXED_OVERHEAD -> <> = Bin, parse_decoded(V, T, C, D, H, Rest); decode(_) -> {error, truncated}. -spec parse_decoded(byte(), byte(), byte(), non_neg_integer(), binary(), binary()) -> {ok, header()} | {error, decode_error()}. parse_decoded(V, _T, _C, _D, _H, _Rest) when V =/= ?VERSION -> {error, bad_version}; parse_decoded(_V, T, _C, _D, _H, _Rest) when T < 1; T > ?MAX_HOPS -> {error, bad_total_hops}; parse_decoded(_V, T, C, _D, _H, _Rest) when C > T -> {error, bad_current_hop}; parse_decoded(V, T, C, D, H, Rest) -> parse_hops(V, T, C, D, H, Rest). -spec parse_hops(byte(), byte(), byte(), non_neg_integer(), binary(), binary()) -> {ok, header()} | {error, decode_error()}. parse_hops(V, T, C, D, H, Rest) when byte_size(Rest) >= T * ?HOP_BYTES -> HopsByteCount = T * ?HOP_BYTES, <> = Rest, Hops = [Hop || <> <= HopsBin], verify_hash(compute_hash(Hops) =:= H, V, T, C, D, H, Hops); parse_hops(_V, _T, _C, _D, _H, _Rest) -> {error, truncated}. -spec verify_hash(boolean(), byte(), byte(), byte(), non_neg_integer(), binary(), [hop_id()]) -> {ok, header()} | {error, decode_error()}. verify_hash(false, _V, _T, _C, _D, _H, _Hops) -> {error, path_hash_mismatch}; verify_hash(true, V, T, C, D, H, Hops) -> {ok, #{version => V, total_hops => T, current_hop => C, deadline => D, path_hash => H, hops => Hops}}. %%===================================================================== %% Verification + position management %%===================================================================== %% @doc Recompute the path_hash and check it matches the header's %% claim. `decode/1' already runs this check, so callers only need %% `verify/1' when they synthesise headers in memory or want a %% defensive check after a structural mutation. -spec verify(header()) -> ok | {error, path_hash_mismatch}. verify(#{path_hash := H, hops := Hops}) -> verify_compare(compute_hash(Hops) =:= H). verify_compare(true) -> ok; verify_compare(false) -> {error, path_hash_mismatch}. %% @doc Advance to the next hop. Errors if already complete. -spec advance(header()) -> header(). advance(#{current_hop := C, total_hops := T} = H) when C < T -> H#{current_hop := C + 1}; advance(_) -> error(path_already_complete). %%===================================================================== %% Accessors %%===================================================================== -spec version(header()) -> non_neg_integer(). version(#{version := V}) -> V. -spec total_hops(header()) -> pos_integer(). total_hops(#{total_hops := T}) -> T. -spec current_hop(header()) -> non_neg_integer(). current_hop(#{current_hop := C}) -> C. -spec deadline(header()) -> non_neg_integer(). deadline(#{deadline := D}) -> D. -spec path_hash(header()) -> <<_:128>>. path_hash(#{path_hash := H}) -> H. -spec hops(header()) -> [hop_id(), ...]. hops(#{hops := Hops}) -> Hops. %% @doc The hop currently processing this frame (i.e. the receiver %% expected to handle this incoming CALL hop). `error' if the %% header is already complete. -spec current_hop_id(header()) -> {ok, hop_id()} | error. current_hop_id(#{current_hop := C, total_hops := T}) when C >= T -> error; current_hop_id(#{current_hop := C, hops := Hops}) -> {ok, lists:nth(C + 1, Hops)}. %% @doc The hop the current receiver should forward to next. %% Returns `error' if the current hop is the final one (no next %% hop to forward to — the call is delivered locally). -spec next_hop_id(header()) -> {ok, hop_id()} | error. next_hop_id(#{current_hop := C, total_hops := T}) when C + 1 >= T -> error; next_hop_id(#{current_hop := C, hops := Hops}) -> {ok, lists:nth(C + 2, Hops)}. %% @doc `true' iff every hop has been traversed (`current_hop == %% total_hops'). The final receiver advances once after delivery %% to mark the path complete. -spec is_complete(header()) -> boolean(). is_complete(#{current_hop := C, total_hops := T}) -> C >= T. %% @doc `true' iff the receiver of this frame is the destination — %% no further forward needed. -spec is_final_hop(header()) -> boolean(). is_final_hop(#{current_hop := C, total_hops := T}) -> C + 1 =:= T. %%===================================================================== %% Hop-ID truncation %%===================================================================== %% @doc Reduce a 32-byte NodeId (or anything ≥16 bytes) to its %% first 16 bytes, matching the wire layout. 16-byte inputs are %% returned unchanged. -spec truncate_hop(binary()) -> hop_id(). truncate_hop(<>) -> H; truncate_hop(<>) -> H. %%===================================================================== %% Internals %%===================================================================== -spec valid_total(integer()) -> ok. valid_total(N) when N >= 1, N =< ?MAX_HOPS -> ok. -spec valid_current(integer(), integer()) -> ok. valid_current(C, T) when C >= 0, C =< T -> ok. -spec compute_hash([binary()]) -> <<_:128>>. compute_hash(Hops) -> Concat = iolist_to_binary(Hops), <> = crypto:hash(sha256, Concat), Trunc.