%% @doc Deterministic CBOR encoder/decoder. %% %% Implements the subset of RFC 8949 needed by Macula records: %% unsigned ints, byte strings, text strings, arrays, maps, and `null'. %% %% Encoding follows RFC 8949 §4.2.1 (deterministic): %% %% %% Internal value representation: %% -module(macula_record_cbor). -export([encode/1, decode/1]). -export_type([value/0]). -type value() :: integer() | binary() | {text, binary()} | [value()] | #{value() => value()} | null | atom(). -define(MAX_UINT64, 16#FFFFFFFFFFFFFFFF). %%------------------------------------------------------------------ %% Encode %%------------------------------------------------------------------ -spec encode(value()) -> binary(). encode(N) when is_integer(N), N >= 0, N =< ?MAX_UINT64 -> head(0, N); %% Negative integers — CBOR major type 1. The encoded count is `-1 - N' %% (so -1 -> head(1, 0), -11 -> head(1, 10)). Bounded the same as the %% positive branch but mirrored on the negative side. encode(N) when is_integer(N), N < 0, N >= -(?MAX_UINT64 + 1) -> head(1, -1 - N); encode({text, B}) when is_binary(B) -> <<(head(3, byte_size(B)))/binary, B/binary>>; encode(B) when is_binary(B) -> <<(head(2, byte_size(B)))/binary, B/binary>>; encode(L) when is_list(L) -> encode_array(L); encode(M) when is_map(M) -> encode_map(M); encode(null) -> <<16#F6>>; %% Atoms encode as their UTF-8 name as a major-3 text string. This %% makes the codec self-healing across the wire round-trip: the frame %% decoder atomizes binary keys via binary_to_existing_atom/1, and %% records re-encoded for signature verify hit those atoms here. By %% the symmetry of atom_to_binary/1 / binary_to_existing_atom/1, %% the resulting wire bytes match the original record exactly. encode(A) when is_atom(A) -> Bin = atom_to_binary(A, utf8), <<(head(3, byte_size(Bin)))/binary, Bin/binary>>. encode_array(L) -> Body = << <<(encode(E))/binary>> || E <- L >>, <<(head(4, length(L)))/binary, Body/binary>>. encode_map(M) -> %% Encode each k/v independently, then sort by encoded key bytes %% (Erlang binary comparison is bytewise — exactly what the spec wants). Pairs = [ {encode(K), encode(V)} || {K, V} <- maps:to_list(M) ], Sorted = lists:sort(Pairs), Body = << <> || {K, V} <- Sorted >>, <<(head(5, maps:size(M)))/binary, Body/binary>>. %% Type byte + length prefix using the smallest encoding. head(MT, N) when N =< 23 -> <>; head(MT, N) when N =< 16#FF -> <>; head(MT, N) when N =< 16#FFFF -> <>; head(MT, N) when N =< 16#FFFFFFFF -> <>; head(MT, N) when N =< ?MAX_UINT64 -> <>. %%------------------------------------------------------------------ %% Decode %%------------------------------------------------------------------ -spec decode(binary()) -> value(). decode(Bin) when is_binary(Bin) -> {V, <<>>} = decode_one(Bin), V. %% Major 7, value 22 = null. Anything else with major 7 is unsupported. decode_one(<<7:3, 22:5, R/binary>>) -> {null, R}; decode_one(<>) -> {N, R} = decode_count(AI, Rest), decode_value(MT, N, R). decode_count(AI, R) when AI =< 23 -> {AI, R}; decode_count(24, <>) -> {N, R}; decode_count(25, <>) -> {N, R}; decode_count(26, <>) -> {N, R}; decode_count(27, <>) -> {N, R}. decode_value(0, N, R) -> {N, R}; %% Negative integer (major 1) — the encoded count `N' represents the %% integer `-1 - N'. decode_value(1, N, R) -> {-1 - N, R}; decode_value(2, Len, R) -> <> = R, {B, Rest}; decode_value(3, Len, R) -> <> = R, {{text, B}, Rest}; decode_value(4, Len, R) -> decode_array(Len, R, []); decode_value(5, Len, R) -> decode_map(Len, R, #{}). decode_array(0, R, Acc) -> {lists:reverse(Acc), R}; decode_array(N, R, Acc) -> {V, R1} = decode_one(R), decode_array(N - 1, R1, [V | Acc]). decode_map(0, R, Acc) -> {Acc, R}; decode_map(N, R, Acc) -> {K, R1} = decode_one(R), {V, R2} = decode_one(R1), decode_map(N - 1, R2, Acc#{K => V}).