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