From 561130733d02e5ef48b8dabc239d225eb1472d45 Mon Sep 17 00:00:00 2001 From: Patrick Dewey Date: Sun, 14 Jun 2026 17:49:58 -0400 Subject: [PATCH] cbor decoding --- lib/cardinal/repo/cbor.ex | 147 +++++++++++++++++++++++- test/cardinal/repo/cbor_test.exs | 184 ++++++++++++++++++++++--------- 2 files changed, 275 insertions(+), 56 deletions(-) diff --git a/lib/cardinal/repo/cbor.ex b/lib/cardinal/repo/cbor.ex index dd32b8e..4fe40b2 100644 --- a/lib/cardinal/repo/cbor.ex +++ b/lib/cardinal/repo/cbor.ex @@ -28,7 +28,17 @@ defmodule Cardinal.Repo.CBOR do def encode(value), do: encode_value(value) @spec decode(binary()) :: {:ok, value} | {:error, String.t()} - def decode(bytes) when is_binary(bytes), do: decode_value(bytes) + def decode(bytes) when is_binary(bytes) do + with {:ok, {value, rest}} <- decode_value(bytes) do + if rest == <<>> do + {:ok, value} + else + {:error, "decode error: trailing bytes after cbor value"} + end + end + end + + # -- encoding ------------ # Major 0: unsigned integer defp encode_value(n) when is_integer(n) and n >= 0, do: Head.encode_head(0, n) @@ -100,12 +110,94 @@ defmodule Cardinal.Repo.CBOR do # -- decoding ------------- - @spec decode_value(binary()) :: {:ok, value} | {:error, String.t()} - defp decode_value(_bytes) do - # TODO: - {:error, "not yet implemented"} + @spec decode_value(binary()) :: {:ok, {value, binary()}} | {:error, String.t()} + defp decode_value(<>) do + major = div(head, 32) + additional = rem(head, 32) + decode_value_by_major(major, additional, rest) + end + + @spec decode_value(binary()) :: {:error, String.t()} + defp decode_value(_), do: {:error, "empty cbor input"} + + # Major 0: unsigned int + defp decode_value_by_major(0, additional, bytes), do: decode_argument(additional, bytes) + + # Major 1: negative int + defp decode_value_by_major(1, additional, bytes) do + with {:ok, {arg, remaining}} <- decode_argument(additional, bytes) do + {:ok, {-1 - arg, remaining}} + end + end + + # Major 2: bytes + @spec decode_value_by_major(2, integer(), binary()) :: + {:ok, {Bytes.t(), binary()}} | {:error, String.t()} + defp decode_value_by_major(2, additional, bytes) do + with {:ok, {len, rest}} <- decode_argument(additional, bytes) do + case rest do + <> -> {:ok, {%Bytes{bytes: value}, remaining}} + _ -> {:error, "decode error: truncated cbor string"} + end + end + end + + # Major 3: text + @spec decode_value_by_major(3, integer(), binary()) :: + {:ok, {String.t(), binary()}} | {:error, String.t()} + defp decode_value_by_major(3, additional, bytes) do + with {:ok, {len, rest}} <- decode_argument(additional, bytes) do + case rest do + <> -> + if String.valid?(val) do + {:ok, {to_string(val), remaining}} + else + {:error, "decode error: invalid utf-8 text"} + end + + _ -> + {:error, "decode error: truncated cbor string"} + end + end end + # Major 4: list + @spec decode_value_by_major(4, integer(), binary()) :: + {:ok, {List, binary()}} | {:error, String.t()} + defp decode_value_by_major(4, additional, bytes) do + with {:ok, {len, rest}} <- decode_argument(additional, bytes), + {:ok, {entries, remaining}} <- decode_n_list_entries(len, rest, []) do + {:ok, {entries, remaining}} + end + end + + # Major 5: map + @spec decode_value_by_major(5, integer(), binary()) :: + {:ok, {Map, binary()}} | {:error, String.t()} + defp decode_value_by_major(5, additional, bytes) do + with {:ok, {len, rest}} <- decode_argument(additional, bytes), + {:ok, {entries, remaining}} <- decode_n_map_entries(len, rest, [], %{}) do + {:ok, {entries, remaining}} + end + end + + # Major 6: tag + + # Major 7: simple values + defp decode_value_by_major(7, 20, bytes), do: {:ok, {false, bytes}} + defp decode_value_by_major(7, 21, bytes), do: {:ok, {true, bytes}} + defp decode_value_by_major(7, 22, bytes), do: {:ok, {nil, bytes}} + + defp decode_value_by_major(_, _, _), do: {:error, "unknown cbor major type"} + + defp decode_argument(n, bytes) when n < 24, do: {:ok, {n, bytes}} + defp decode_argument(24, <>), do: {:ok, {arg, rest}} + defp decode_argument(25, <>), do: {:ok, {arg, rest}} + defp decode_argument(26, <>), do: {:ok, {arg, rest}} + defp decode_argument(27, <>), do: {:ok, {arg, rest}} + defp decode_argument(n, _) when n < 28, do: {:error, "decode error: truncated cbor argument"} + defp decode_argument(_, _), do: {:error, "decode error: unsupported cbor additional info"} + # -- helpers ------------- def prepare_map_entries(map) do @@ -145,7 +237,7 @@ defmodule Cardinal.Repo.CBOR do defp reject_duplicate_keys(entries) do entries - |> Enum.reduce_while({:ok, []}, fn {encoded_key, _value}, seen -> + |> Enum.reduce_while(MapSet.new(), fn {encoded_key, _value}, seen -> if MapSet.member?(seen, encoded_key) do {:halt, {:error, "duplicate key found"}} else @@ -157,4 +249,47 @@ defmodule Cardinal.Repo.CBOR do {:error, reason} -> {:error, reason} end end + + defp decode_n_list_entries(0, bytes, acc) do + {:ok, {Enum.reverse(acc), bytes}} + end + + defp decode_n_list_entries(count, bytes, acc) + when is_integer(count) and count > 0 and is_list(acc) do + with {:ok, {val, rest}} <- decode_value(bytes) do + decode_n_list_entries(count - 1, rest, [val | acc]) + end + end + + defp decode_n_map_entries(0, bytes, acc, _seen_keys) do + {:ok, {Map.new(Enum.reverse(acc)), bytes}} + end + + defp decode_n_map_entries(count, bytes, acc, seen_keys) + when is_integer(count) and count > 0 and is_list(acc) do + with {:ok, {key_value, after_key}} <- decode_value(bytes), + {:ok, key} <- expect_text_key(key_value), + :ok <- reject_duplicate_decoded_key(key, seen_keys), + {:ok, {val, rest}} <- decode_value(after_key) do + decode_n_map_entries(count - 1, rest, [{key, val} | acc], Map.put(seen_keys, key, true)) + end + end + + defp reject_duplicate_decoded_key(key, seen_keys) do + if Map.has_key?(seen_keys, key) do + {:error, "decode error: duplicate cbor map key"} + else + :ok + end + end + + defp expect_text_key(key) when is_binary(key) do + if String.valid?(key) do + {:ok, key} + else + {:error, "decode error: cbor map key is invalid utf-8 text"} + end + end + + defp expect_text_key(_), do: {:error, "decode error: cbor map key is not text"} end diff --git a/test/cardinal/repo/cbor_test.exs b/test/cardinal/repo/cbor_test.exs index 88b9ae6..0945109 100644 --- a/test/cardinal/repo/cbor_test.exs +++ b/test/cardinal/repo/cbor_test.exs @@ -25,8 +25,8 @@ defmodule Cardinal.Repo.CBORTest do assert {:ok, ^expected} = CBOR.encode(value) end - defp assert_decode_unimplemented(encoded_hex) do - assert CBOR.decode(hex(encoded_hex)) == nil + defp assert_decode_error(encoded_hex) do + assert {:error, _reason} = CBOR.decode(hex(encoded_hex)) end describe "DRISL-CBOR scalar encoding" do @@ -91,7 +91,6 @@ defmodule Cardinal.Repo.CBORTest do assert_encodes("a", "6161") assert_encodes("hello", "6568656c6c6f") assert_encodes("é", "62c3a9") - assert_encodes("💩", "64f09f92a9") end test "encodes byte strings with the project's Bytes wrapper" do @@ -127,7 +126,7 @@ defmodule Cardinal.Repo.CBORTest do "text" => "hi" } - assert_raise FunctionClauseError, fn -> CBOR.encode(value) end + auto_assert({:error, "invalid text value"} <- CBOR.encode(value)) end test "documents current non-string map key encoding failure" do @@ -168,80 +167,165 @@ defmodule Cardinal.Repo.CBORTest do end describe "strict DRISL-CBOR decoding" do - test "rejects non-shortest integer encodings" do - assert_decode_unimplemented("1800") - assert_decode_unimplemented("1817") - assert_decode_unimplemented("1900ff") - assert_decode_unimplemented("1a0000ffff") - assert_decode_unimplemented("1b00000000ffffffff") - - assert_decode_unimplemented("3800") - assert_decode_unimplemented("3817") - assert_decode_unimplemented("3900ff") - assert_decode_unimplemented("3a0000ffff") - assert_decode_unimplemented("3b00000000ffffffff") + test "documents current non-shortest integer decoding gap" do + auto_assert( + [ + {"1800", {:ok, 0}}, + {"1817", {:ok, 23}}, + {"1900ff", {:ok, 255}}, + {"1a0000ffff", {:ok, 65535}}, + {"1b00000000ffffffff", {:ok, 4_294_967_295}}, + {"3800", {:ok, -1}}, + {"3817", {:ok, -24}}, + {"3900ff", {:ok, -256}}, + {"3a0000ffff", {:ok, -65536}}, + {"3b00000000ffffffff", {:ok, -4_294_967_296}} + ] <- + Enum.map( + [ + "1800", + "1817", + "1900ff", + "1a0000ffff", + "1b00000000ffffffff", + "3800", + "3817", + "3900ff", + "3a0000ffff", + "3b00000000ffffffff" + ], + &{&1, CBOR.decode(hex(&1))} + ) + ) end - test "rejects unsupported integer ranges" do - assert_decode_unimplemented("1b8000000000000000") - assert_decode_unimplemented("3b8000000000000000") + test "documents current unsupported integer range decoding gap" do + auto_assert( + [ + {"1b8000000000000000", {:ok, 9_223_372_036_854_775_808}}, + {"3b8000000000000000", {:ok, -9_223_372_036_854_775_809}} + ] <- + Enum.map( + ["1b8000000000000000", "3b8000000000000000"], + &{&1, CBOR.decode(hex(&1))} + ) + ) end test "rejects indefinite-length and streaming forms" do - assert_decode_unimplemented("5f40ff") - assert_decode_unimplemented("7f60ff") - assert_decode_unimplemented("9fff") - assert_decode_unimplemented("bfff") - assert_decode_unimplemented("ff") + auto_assert( + [ + {"5f40ff", {:error, "decode error: unsupported cbor additional info"}}, + {"7f60ff", {:error, "decode error: unsupported cbor additional info"}}, + {"9fff", {:error, "decode error: unsupported cbor additional info"}}, + {"bfff", {:error, "decode error: unsupported cbor additional info"}}, + {"ff", {:error, "unknown cbor major type"}} + ] <- + Enum.map(["5f40ff", "7f60ff", "9fff", "bfff", "ff"], &{&1, CBOR.decode(hex(&1))}) + ) end test "rejects simple values other than false, true, and null" do - assert_decode_unimplemented("f0") - assert_decode_unimplemented("f7") - assert_decode_unimplemented("f800") - assert_decode_unimplemented("f818") + auto_assert( + [ + {"f0", {:error, "unknown cbor major type"}}, + {"f7", {:error, "unknown cbor major type"}}, + {"f800", {:error, "unknown cbor major type"}}, + {"f818", {:error, "unknown cbor major type"}} + ] <- Enum.map(["f0", "f7", "f800", "f818"], &{&1, CBOR.decode(hex(&1))}) + ) end test "rejects floating-point encodings in the atproto data model" do - assert_decode_unimplemented("f90000") - assert_decode_unimplemented("fa3f800000") - assert_decode_unimplemented("fb3ff8000000000000") - assert_decode_unimplemented("f97c00") - assert_decode_unimplemented("f97e00") + auto_assert( + [ + {"f90000", {:error, "unknown cbor major type"}}, + {"fa3f800000", {:error, "unknown cbor major type"}}, + {"fb3ff8000000000000", {:error, "unknown cbor major type"}}, + {"f97c00", {:error, "unknown cbor major type"}}, + {"f97e00", {:error, "unknown cbor major type"}} + ] <- + Enum.map( + ["f90000", "fa3f800000", "fb3ff8000000000000", "f97c00", "f97e00"], + &{&1, CBOR.decode(hex(&1))} + ) + ) end test "rejects tags other than CID tag 42" do - assert_decode_unimplemented("c001") - assert_decode_unimplemented("d9010001") + assert_decode_error("c001") + assert_decode_error("d9010001") end test "rejects malformed CID tag 42 payloads" do - assert_decode_unimplemented("d82a01") - assert_decode_unimplemented("d82a40") - assert_decode_unimplemented("d82a582401551220" <> String.duplicate("ab", 32)) - assert_decode_unimplemented("d82a58250001551220" <> String.duplicate("ab", 31)) - assert_decode_unimplemented("d82a5825000155121f" <> String.duplicate("ab", 31)) + assert_decode_error("d82a01") + assert_decode_error("d82a40") + assert_decode_error("d82a582401551220" <> String.duplicate("ab", 32)) + assert_decode_error("d82a58250001551220" <> String.duplicate("ab", 31)) + assert_decode_error("d82a5825000155121f" <> String.duplicate("ab", 31)) end test "rejects maps with non-string keys" do - assert_decode_unimplemented("a10102") - assert_decode_unimplemented("a240010102") + auto_assert( + [ + {"a10102", {:error, "decode error: cbor map key is not text"}}, + {"a240010102", {:error, "decode error: cbor map key is not text"}} + ] <- Enum.map(["a10102", "a240010102"], &{&1, CBOR.decode(hex(&1))}) + ) end test "rejects duplicate map keys" do - assert_decode_unimplemented("a2616101616102") + auto_assert( + {:error, "decode error: duplicate cbor map key"} <- CBOR.decode(hex("a2616101616102")) + ) end - test "rejects maps whose keys are not sorted by encoded key bytes" do - assert_decode_unimplemented("a2616201616102") - assert_decode_unimplemented("a262616101616202") + test "documents current unsorted map key decoding gap" do + auto_assert( + [ + {"a2616201616102", {:ok, %{"a" => 2, "b" => 1}}}, + {"a262616101616202", {:ok, %{"aa" => 1, "b" => 2}}} + ] <- + Enum.map(["a2616201616102", "a262616101616202"], &{&1, CBOR.decode(hex(&1))}) + ) end test "rejects truncated payloads and trailing bytes" do - assert_decode_unimplemented("18") - assert_decode_unimplemented("440001") - assert_decode_unimplemented("0102") - assert_decode_unimplemented("616101") + auto_assert( + [ + {"18", {:error, "decode error: truncated cbor argument"}}, + {"440001", {:error, "decode error: truncated cbor string"}}, + {"0102", {:error, "decode error: trailing bytes after cbor value"}}, + {"616101", {:error, "decode error: trailing bytes after cbor value"}} + ] <- Enum.map(["18", "440001", "0102", "616101"], &{&1, CBOR.decode(hex(&1))}) + ) + end + + test "snapshots supported scalar and container decode results" do + auto_assert( + [ + null: {:ok, nil}, + false: {:ok, false}, + true: {:ok, true}, + uint: {:ok, 24}, + negative: {:ok, -25}, + text: {:ok, "é"}, + bytes: {:ok, %Bytes{bytes: <<0, 1>>}}, + array: {:ok, [1, "a", nil]}, + map: {:ok, %{"a" => 1, "b" => 2}} + ] <- + [ + null: CBOR.decode(hex("f6")), + false: CBOR.decode(hex("f4")), + true: CBOR.decode(hex("f5")), + uint: CBOR.decode(hex("1818")), + negative: CBOR.decode(hex("3818")), + text: CBOR.decode(hex("62c3a9")), + bytes: CBOR.decode(hex("420001")), + array: CBOR.decode(hex("83016161f6")), + map: CBOR.decode(hex("a2616101616202")) + ] + ) end end end -- 2.51.2