diff --git a/lib/dasl/car.ex b/lib/dasl/car.ex index 3574219..0c868a5 100644 --- a/lib/dasl/car.ex +++ b/lib/dasl/car.ex @@ -1,17 +1,99 @@ defmodule DASL.CAR do + @moduledoc """ + Struct and entry points for DASL CAR files. + + Blocks are stored as raw binaries keyed by their `DASL.CID`. For a + higher-level variant that transparently encodes/decodes block data as DRISL, + see `DASL.CAR.DRISL`. + + Spec: https://dasl.ing/car.html + """ + use TypedStruct - alias DASL.CAR + alias DASL.{CAR, CID} typedstruct enforce: true do - field :version, integer() - field :roots, list(binary()) - field :blocks, %{binary() => any()} + field :version, pos_integer(), default: 1 + field :roots, list(CID.t()), default: [] + field :blocks, %{CID.t() => binary()}, default: %{} end @doc """ - Decode a binary CAR file into an Elixir struct. + Decodes a CAR binary stream into a `DASL.CAR` struct. + + ## Options + + * `:verify` — boolean, default `true`. When enabled, each block's raw data + is verified against its CID digest using `DASL.CID.verify?/2`. Returns + `{:error, :block, :cid_mismatch}` on failure. + """ - @spec decode(binary()) :: + @spec decode(binary(), keyword()) :: {:ok, t()} | CAR.Decoder.header_error() | CAR.Decoder.block_error() - def decode(binary), do: CAR.Decoder.decode(binary) + def decode(binary, opts \\ []), do: CAR.Decoder.decode(binary, opts) + + @doc """ + Encodes a `DASL.CAR` struct into a CAR binary stream. + + ## Options + + * `:verify` — boolean, default `true`. When enabled, the block binary is + verified against its CID digest using `DASL.CID.verify?/2`. Returns + `{:error, :block, :cid_mismatch}` on failure. + + """ + @spec encode(t(), keyword()) :: + {:ok, binary()} | CAR.Encoder.header_error() | CAR.Encoder.block_error() + def encode(%CAR{} = car, opts \\ []), do: CAR.Encoder.encode(car, opts) + + @doc """ + Computes the CID for `data`, adds it to the CAR's blocks, and returns the + updated struct alongside the computed CID. + + The CID is computed using the `:raw` codec. + """ + @spec add_block(t(), binary()) :: {t(), CID.t()} + def add_block(%CAR{blocks: blocks} = car, data) when is_binary(data) do + cid = CID.compute(data, :raw) + {%CAR{car | blocks: Map.put(blocks, cid, data)}, cid} + end + + @doc """ + Adds a CID to the CAR's roots after verifying it exists in the blocks. + + Returns `{:ok, updated_car}` on success, `{:ok, car}` unchanged if the CID + is already a root (no-op), or `{:error, :not_in_blocks}` if the CID is not + present in the blocks. + """ + @spec add_root(t(), CID.t()) :: {:ok, t()} | {:error, :not_in_blocks} + def add_root(%CAR{roots: roots, blocks: blocks} = car, %CID{} = cid) do + cond do + cid in roots -> {:ok, car} + not Map.has_key?(blocks, cid) -> {:error, :not_in_blocks} + true -> {:ok, %CAR{car | roots: roots ++ [cid]}} + end + end + + @doc """ + Removes a CID from the CAR's roots. No-op if the CID is not a root. + """ + @spec remove_root(t(), CID.t()) :: t() + def remove_root(%CAR{roots: roots} = car, %CID{} = cid), + do: %CAR{car | roots: List.delete(roots, cid)} + + @doc """ + Removes a CID from the CAR's blocks. + + Returns `{:ok, updated_car}` on success (including when the CID is absent — + no-op), or `{:error, :is_a_root}` if the CID is currently listed as a root. + Call `remove_root/2` first to remove it from roots before removing the block. + """ + @spec remove_block(t(), CID.t()) :: {:ok, t()} | {:error, :is_a_root} + def remove_block(%CAR{roots: roots, blocks: blocks} = car, %CID{} = cid) do + if cid in roots do + {:error, :is_a_root} + else + {:ok, %CAR{car | blocks: Map.delete(blocks, cid)}} + end + end end diff --git a/lib/dasl/car/decoder.ex b/lib/dasl/car/decoder.ex index de8b711..4ded144 100644 --- a/lib/dasl/car/decoder.ex +++ b/lib/dasl/car/decoder.ex @@ -1,19 +1,29 @@ defmodule DASL.CAR.Decoder do @moduledoc """ - Module for decoding DASL CAR files. + Decoder for DASL CAR binary streams. Spec: https://dasl.ing/car.html """ + alias DASL.{CAR, CID, DRISL} + alias Varint.LEB128 + + @cid_byte_size 36 + @type header_error() :: {:error, :header, atom()} @type block_error() :: {:error, :block, atom()} - alias DASL.{CAR, DRISL} - alias Varint.LEB128 + @doc """ + Decodes a CAR binary stream into a `DASL.CAR` struct. + + Accepts the same options as `DASL.CAR.decode/2`. + """ + @spec decode(binary(), keyword()) :: {:ok, CAR.t()} | header_error() | block_error() + def decode(binary, opts \\ []) do + verify = Keyword.get(opts, :verify, true) - @spec decode(binary()) :: {:ok, CAR.t()} | header_error() | block_error() - def decode(binary) do - with {:ok, roots, rest} <- header(binary), - {:ok, blocks} <- data(rest) do + with {:ok, metadata, rest} <- header(binary), + {:ok, blocks} <- blocks(rest, %{}, verify) do + roots = Map.fetch!(metadata, "roots") {:ok, %CAR{version: 1, roots: roots, blocks: blocks}} end end @@ -21,56 +31,92 @@ defmodule DASL.CAR.Decoder do @spec header(binary()) :: {:ok, map(), binary()} | header_error() defp header(binary) do with {length, rest} <- LEB128.decode(binary), - <> <- rest, - # TODO: make sure header is always CBOR - {:ok, %{"version" => 1, "roots" => roots}, <<>>} <- DRISL.decode(header) do - {:ok, roots, rest} + :ok <- validate_header_length(length), + <> <- rest, + {:ok, metadata, <<>>} <- DRISL.decode(header_bin), + :ok <- validate_header_map(metadata) do + {:ok, metadata, rest} else - # Invalid header length <<_::binary>> -> {:error, :header, :too_short} - # Incorrect header structure - {:ok, _, _} -> {:error, :invalid_header} - # CBOR error - {:error, reason} -> {:error, :invalid_header, reason} + {:ok, _, leftover} when is_binary(leftover) -> {:error, :header, :invalid_encoding} + {:error, :header, _} = err -> err + {:error, _reason} -> {:error, :header, :invalid_encoding} + end + end + + @spec validate_header_length(non_neg_integer()) :: :ok | header_error() + defp validate_header_length(0), do: {:error, :header, :empty} + defp validate_header_length(_), do: :ok + + @spec validate_header_map(any()) :: :ok | header_error() + defp validate_header_map(metadata) when not is_map(metadata), + do: {:error, :header, :not_a_map} + + defp validate_header_map(%{"version" => version}) when version != 1, + do: {:error, :header, :unsupported_version} + + defp validate_header_map(%{"version" => 1, "roots" => roots}) when is_list(roots) do + if Enum.all?(roots, &match?(%CID{}, &1)) do + :ok + else + {:error, :header, :invalid_roots} end end - @spec data(binary(), map()) :: {:ok, %{binary() => any()}} | block_error() - defp data(binary, blocks \\ %{}) do + defp validate_header_map(%{"version" => 1}), do: {:error, :header, :missing_roots} + defp validate_header_map(_), do: {:error, :header, :missing_version} + + @spec blocks(binary(), map(), boolean()) :: {:ok, map()} | block_error() + defp blocks(<<>>, acc, _verify), do: {:ok, acc} + + defp blocks(binary, acc, verify) do with {length, rest} <- LEB128.decode(binary), - <> <- rest, - {_version, cid, data} <- cid(cid_data), - # TODO: support DAG-PB & RAW modes - {:ok, block, <<>>} <- DRISL.decode(data), - blocks <- Map.put(blocks, cid, block) do - case rest do - <<>> -> {:ok, blocks} - rest -> data(rest, blocks) - end + :ok <- validate_block_length(length), + <> <- rest, + {:ok, cid} <- parse_cid(cid_bytes), + :ok <- maybe_verify(verify, cid, data) do + blocks(rest, Map.put(acc, cid, data), verify) else <<_::binary>> -> {:error, :block, :too_short} - {:ok, _, _} -> {:error, :block, :incorrect_length} + {:error, :block, _} = err -> err {:error, reason} -> {:error, :block, reason} end end - @doc """ - Extract a CIDv0 or CIDv1 from an arbitrary byte stream. - """ - @spec cid(binary()) :: {integer(), binary(), binary()} - def cid(<<0x12, 0x20, id::32, rest::binary>>) do - {0, <<0x12, 0x20, id>>, rest} + @spec validate_block_length(non_neg_integer()) :: :ok | block_error() + defp validate_block_length(length) when length < @cid_byte_size, + do: {:error, :block, :too_short} + + defp validate_block_length(_), do: :ok + + @spec parse_cid(binary()) :: {:ok, CID.t()} | block_error() + defp parse_cid(cid_bytes) do + case CID.decode(cid_bytes) do + {:ok, fields} -> + {:ok, + struct!(CID, + version: fields.version, + codec: fields.codec, + hash_type: fields.hash_type, + hash_size: fields.hash_size, + digest: fields.digest, + bytes: cid_bytes + )} + + {:error, reason} -> + {:error, :block, {:invalid_cid, reason}} + end end - def cid(<<0x01, binary::binary>>) do - # CIDv1 = <> where multihash = <> - # {1 = version, rest} = LEB128.decode(binary) - {codec, <>} = LEB128.decode(binary) - {mh_length, _} = LEB128.decode(mh_2) - <> = rest - multihash = <> - cid = <<0x01, LEB128.encode(codec)::binary, multihash::binary>> + @spec maybe_verify(boolean(), CID.t(), binary()) :: :ok | block_error() + defp maybe_verify(false, _cid, _data), do: :ok - {1, cid, rest} + defp maybe_verify(true, cid, data) do + if CID.verify?(cid, data) do + :ok + else + {:error, :block, :cid_mismatch} + end end end diff --git a/lib/dasl/car/drisl.ex b/lib/dasl/car/drisl.ex new file mode 100644 index 0000000..5f4f94f --- /dev/null +++ b/lib/dasl/car/drisl.ex @@ -0,0 +1,144 @@ +defmodule DASL.CAR.DRISL do + @moduledoc """ + A DRISL-aware CAR variant. + + Wraps `DASL.CAR` with transparent DRISL encoding and decoding of block data. + Blocks are stored as decoded Elixir terms keyed by their `DASL.CID`. CIDs are + computed over the DRISL-encoded form of each term using the `:drisl` codec. + + For a lower-level CAR that stores raw binaries, see `DASL.CAR`. + """ + + use TypedStruct + alias DASL.{CAR, CID, DRISL} + + typedstruct enforce: true do + field :version, pos_integer(), default: 1 + field :roots, list(CID.t()), default: [] + field :blocks, %{CID.t() => any()}, default: %{} + end + + @doc """ + Decodes a CAR binary stream into a `DASL.CAR.DRISL` struct. + + Each block's raw binary is decoded as a DRISL term after CID verification. + + ## Options + + * `:verify` — boolean, default `true`. Verifies each block's raw data + against its CID before DRISL decoding. Returns + `{:error, :block, :cid_mismatch}` on failure. + + """ + @spec decode(binary(), keyword()) :: + {:ok, t()} | CAR.Decoder.header_error() | CAR.Decoder.block_error() + def decode(binary, opts \\ []) do + with {:ok, raw_car} <- CAR.decode(binary, opts), + {:ok, decoded_blocks} <- decode_blocks(raw_car.blocks) do + {:ok, + %__MODULE__{ + version: raw_car.version, + roots: raw_car.roots, + blocks: decoded_blocks + }} + end + end + + @doc """ + Encodes a `DASL.CAR.DRISL` struct into a CAR binary stream. + + Each block's term value is DRISL-encoded before writing. + + ## Options + + * `:verify` — boolean, default `true`. Verifies each DRISL-encoded block + binary against its CID before writing. Returns + `{:error, :block, :cid_mismatch}` on failure. + + """ + @spec encode(t(), keyword()) :: + {:ok, binary()} | CAR.Encoder.header_error() | CAR.Encoder.block_error() + def encode(%__MODULE__{version: 1, roots: roots, blocks: blocks}, opts \\ []) do + with {:ok, encoded_blocks} <- encode_blocks(blocks) do + raw_car = %CAR{version: 1, roots: roots, blocks: encoded_blocks} + CAR.encode(raw_car, opts) + end + end + + @doc """ + DRISL-encodes `term`, computes its CID using the `:drisl` codec, adds the CID + to the blocks (storing the original term, not the encoded binary), and returns + the updated struct alongside the computed CID. + + Returns `{:error, reason}` if DRISL encoding fails. + """ + @spec add_block(t(), any()) :: {:ok, {t(), CID.t()}} | {:error, atom()} + def add_block(%__MODULE__{blocks: blocks} = car, term) do + with {:ok, encoded} <- DRISL.encode(term) do + cid = CID.compute(encoded, :drisl) + {:ok, {%__MODULE__{car | blocks: Map.put(blocks, cid, term)}, cid}} + end + end + + @doc """ + Adds a CID to the CAR's roots after verifying it exists in the blocks. + + Returns `{:ok, updated_car}` on success, `{:ok, car}` unchanged if the CID + is already a root (no-op), or `{:error, :not_in_blocks}` if the CID is not + present in the blocks. + """ + @spec add_root(t(), CID.t()) :: {:ok, t()} | {:error, :not_in_blocks} + def add_root(%__MODULE__{roots: roots, blocks: blocks} = car, %CID{} = cid) do + cond do + cid in roots -> {:ok, car} + not Map.has_key?(blocks, cid) -> {:error, :not_in_blocks} + true -> {:ok, %__MODULE__{car | roots: roots ++ [cid]}} + end + end + + @doc """ + Removes a CID from the CAR's roots. No-op if the CID is not a root. + """ + @spec remove_root(t(), CID.t()) :: t() + def remove_root(%__MODULE__{roots: roots} = car, %CID{} = cid), + do: %__MODULE__{car | roots: List.delete(roots, cid)} + + @doc """ + Removes a CID from the CAR's blocks. + + Returns `{:ok, updated_car}` on success (including when the CID is absent — + no-op), or `{:error, :is_a_root}` if the CID is currently listed as a root. + Call `remove_root/2` first. + """ + @spec remove_block(t(), CID.t()) :: {:ok, t()} | {:error, :is_a_root} + def remove_block(%__MODULE__{roots: roots, blocks: blocks} = car, %CID{} = cid) do + if cid in roots do + {:error, :is_a_root} + else + {:ok, %__MODULE__{car | blocks: Map.delete(blocks, cid)}} + end + end + + @spec decode_blocks(%{CID.t() => binary()}) :: + {:ok, %{CID.t() => any()}} | CAR.Decoder.block_error() + defp decode_blocks(blocks) do + Enum.reduce_while(blocks, {:ok, %{}}, fn {cid, bin}, {:ok, acc} -> + case DRISL.decode(bin) do + {:ok, term, ""} -> {:cont, {:ok, Map.put(acc, cid, term)}} + {:ok, _, _} -> {:halt, {:error, :block, :trailing_bytes}} + {:error, reason} -> {:halt, {:error, :block, reason}} + end + end) + end + + @spec encode_blocks(%{CID.t() => any()}) :: + {:ok, %{CID.t() => binary()}} | CAR.Encoder.block_error() + defp encode_blocks(blocks) do + Enum.reduce_while(blocks, {:ok, %{}}, fn {cid, term}, {:ok, acc} -> + case DRISL.encode(term) do + {:ok, bin} -> {:cont, {:ok, Map.put(acc, cid, bin)}} + {:error, reason} -> {:halt, {:error, :block, reason}} + end + end) + end +end diff --git a/lib/dasl/car/encoder.ex b/lib/dasl/car/encoder.ex new file mode 100644 index 0000000..e6a51b4 --- /dev/null +++ b/lib/dasl/car/encoder.ex @@ -0,0 +1,79 @@ +defmodule DASL.CAR.Encoder do + @moduledoc """ + Encoder for DASL CAR binary streams. + Spec: https://dasl.ing/car.html + """ + + alias DASL.{CAR, CID, DRISL} + alias Varint.LEB128 + + @type header_error() :: {:error, :header, atom()} + @type block_error() :: {:error, :block, atom()} + + @doc """ + Encodes a `DASL.CAR` struct into a CAR binary stream. + + Accepts the same options as `DASL.CAR.encode/2`. + """ + @spec encode(CAR.t(), keyword()) :: {:ok, binary()} | header_error() | block_error() + def encode(%CAR{version: 1, roots: roots, blocks: blocks}, opts \\ []) do + verify = Keyword.get(opts, :verify, true) + + with {:ok, header_bin} <- encode_header(roots), + {:ok, blocks_bin} <- encode_blocks(blocks, verify) do + {:ok, header_bin <> blocks_bin} + end + end + + @spec encode_header(list(CID.t())) :: {:ok, binary()} | header_error() + defp encode_header(roots) do + with :ok <- validate_roots(roots), + {:ok, metadata_bin} <- DRISL.encode(%{"version" => 1, "roots" => roots}) do + {:ok, LEB128.encode(byte_size(metadata_bin)) <> metadata_bin} + else + {:error, :header, _} = err -> err + {:error, reason} -> {:error, :header, reason} + end + end + + @spec validate_roots(any()) :: :ok | header_error() + defp validate_roots(roots) when not is_list(roots), do: {:error, :header, :invalid_roots} + + defp validate_roots(roots) do + if Enum.all?(roots, &match?(%CID{}, &1)) do + :ok + else + {:error, :header, :invalid_roots} + end + end + + @spec encode_blocks(map(), boolean()) :: {:ok, binary()} | block_error() + defp encode_blocks(blocks, verify) do + Enum.reduce_while(blocks, {:ok, <<>>}, fn {cid, data}, {:ok, acc} -> + case encode_block(cid, data, verify) do + {:ok, block_bin} -> {:cont, {:ok, acc <> block_bin}} + {:error, _, _} = err -> {:halt, err} + end + end) + end + + @spec encode_block(CID.t(), binary(), boolean()) :: {:ok, binary()} | block_error() + defp encode_block(%CID{bytes: cid_bytes} = cid, data, verify) when is_binary(data) do + with :ok <- maybe_verify(verify, cid, data) do + {:ok, LEB128.encode(byte_size(cid_bytes) + byte_size(data)) <> cid_bytes <> data} + end + end + + defp encode_block(_cid, _data, _verify), do: {:error, :block, :data_must_be_binary} + + @spec maybe_verify(boolean(), CID.t(), binary()) :: :ok | block_error() + defp maybe_verify(false, _cid, _data), do: :ok + + defp maybe_verify(true, cid, data) do + if CID.verify?(cid, data) do + :ok + else + {:error, :block, :cid_mismatch} + end + end +end diff --git a/test/dasl/car/drisl_test.exs b/test/dasl/car/drisl_test.exs new file mode 100644 index 0000000..da1006c --- /dev/null +++ b/test/dasl/car/drisl_test.exs @@ -0,0 +1,292 @@ +defmodule DASL.CAR.DRISLTest do + use ExUnit.Case, async: true + + alias DASL.{CID, DRISL} + alias DASL.CAR.DRISL, as: DrislCAR + alias Varint.LEB128 + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Builds a raw CAR binary with DRISL-encoded block data, bypassing the + # encoder so decode tests aren't coupled to encode correctness. + defp build_drisl_car_binary(roots, blocks) do + {:ok, header_bin} = DRISL.encode(%{"version" => 1, "roots" => roots}) + header = LEB128.encode(byte_size(header_bin)) <> header_bin + + body = + Enum.reduce(blocks, <<>>, fn {%CID{bytes: cid_bytes}, term}, acc -> + {:ok, encoded} = DRISL.encode(term) + length = byte_size(cid_bytes) + byte_size(encoded) + acc <> LEB128.encode(length) <> cid_bytes <> encoded + end) + + header <> body + end + + # --------------------------------------------------------------------------- + # Round-trip — encode / decode + # --------------------------------------------------------------------------- + + describe "round-trip" do + test "single DRISL term block, no roots" do + term = %{"key" => 42, "flag" => true} + {:ok, encoded} = DRISL.encode(term) + cid = CID.compute(encoded, :drisl) + + car = %DrislCAR{version: 1, roots: [], blocks: %{cid => term}} + + assert {:ok, car_bin} = DrislCAR.encode(car) + assert {:ok, decoded} = DrislCAR.decode(car_bin) + + assert decoded.version == 1 + assert decoded.roots == [] + assert Map.fetch!(decoded.blocks, cid) == term + end + + test "multiple blocks with a root" do + term1 = %{"id" => 1} + term2 = [1, 2, 3] + + {:ok, enc1} = DRISL.encode(term1) + {:ok, enc2} = DRISL.encode(term2) + cid1 = CID.compute(enc1, :drisl) + cid2 = CID.compute(enc2, :drisl) + + car = %DrislCAR{version: 1, roots: [cid1], blocks: %{cid1 => term1, cid2 => term2}} + + assert {:ok, car_bin} = DrislCAR.encode(car) + assert {:ok, decoded} = DrislCAR.decode(car_bin) + + assert decoded.roots == [cid1] + assert Map.fetch!(decoded.blocks, cid1) == term1 + assert Map.fetch!(decoded.blocks, cid2) == term2 + end + + test "empty blocks" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + + assert {:ok, car_bin} = DrislCAR.encode(car) + assert {:ok, decoded} = DrislCAR.decode(car_bin) + + assert decoded.blocks == %{} + end + end + + # --------------------------------------------------------------------------- + # Decoder — verify: true (default) + # --------------------------------------------------------------------------- + + describe "decode with verify: true (default)" do + test "returns :cid_mismatch when block data has been tampered" do + term = %{"legit" => true} + {:ok, encoded} = DRISL.encode(term) + cid = CID.compute(encoded, :drisl) + + tampered_term = %{"legit" => false} + {:ok, tampered_encoded} = DRISL.encode(tampered_term) + + car_bin = build_drisl_car_binary([], [{cid, tampered_term}]) + + # verify the tampered binary is actually different so this test is meaningful + assert tampered_encoded != encoded + assert {:error, :block, :cid_mismatch} = DrislCAR.decode(car_bin) + end + + test "accepts correct data" do + term = %{"correct" => true} + {:ok, encoded} = DRISL.encode(term) + cid = CID.compute(encoded, :drisl) + + car_bin = build_drisl_car_binary([], [{cid, term}]) + + assert {:ok, _} = DrislCAR.decode(car_bin) + end + end + + # --------------------------------------------------------------------------- + # Decoder — verify: false + # --------------------------------------------------------------------------- + + describe "decode with verify: false" do + test "passes tampered block data through without error" do + term = %{"legit" => true} + {:ok, encoded} = DRISL.encode(term) + cid = CID.compute(encoded, :drisl) + + tampered_term = %{"legit" => false} + car_bin = build_drisl_car_binary([], [{cid, tampered_term}]) + + assert {:ok, decoded} = DrislCAR.decode(car_bin, verify: false) + assert Map.fetch!(decoded.blocks, cid) == tampered_term + end + end + + # --------------------------------------------------------------------------- + # Encoder — verify: true (default) + # --------------------------------------------------------------------------- + + describe "encode with verify: true (default)" do + test "returns :cid_mismatch when a block CID does not match its term" do + wrong_cid = CID.compute("unrelated data", :drisl) + car = %DrislCAR{version: 1, roots: [], blocks: %{wrong_cid => %{"actual" => "term"}}} + + assert {:error, :block, :cid_mismatch} = DrislCAR.encode(car) + end + + test "accepts blocks where CID matches the DRISL-encoded term" do + term = %{"valid" => true} + {:ok, encoded} = DRISL.encode(term) + cid = CID.compute(encoded, :drisl) + car = %DrislCAR{version: 1, roots: [], blocks: %{cid => term}} + + assert {:ok, _} = DrislCAR.encode(car) + end + end + + # --------------------------------------------------------------------------- + # Encoder — verify: false + # --------------------------------------------------------------------------- + + describe "encode with verify: false" do + test "writes a mismatched CID without error" do + wrong_cid = CID.compute("unrelated", :drisl) + car = %DrislCAR{version: 1, roots: [], blocks: %{wrong_cid => %{"data" => 1}}} + + assert {:ok, bin} = DrislCAR.encode(car, verify: false) + assert is_binary(bin) + end + end + + # --------------------------------------------------------------------------- + # add_block/2 + # --------------------------------------------------------------------------- + + describe "add_block/2" do + test "DRISL-encodes the term, computes CID on encoded form, stores original term" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + term = %{"hello" => "world"} + + assert {:ok, {updated_car, cid}} = DrislCAR.add_block(car, term) + + assert Map.fetch!(updated_car.blocks, cid) == term + end + + test "CID uses :drisl codec" do + {:ok, {_, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"a" => 1}) + + assert cid.codec == :drisl + end + + test "CID verifies against the DRISL-encoded form of the term" do + term = %{"verifiable" => true} + + {:ok, {_, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, term) + + {:ok, encoded} = DRISL.encode(term) + assert CID.verify?(cid, encoded) + end + + test "CID does not verify against the raw term representation" do + term = %{"verifiable" => true} + + {:ok, {_, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, term) + + refute CID.verify?(cid, :erlang.term_to_binary(term)) + end + + test "returns error when term cannot be DRISL-encoded" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + + assert {:error, _} = DrislCAR.add_block(car, %{1 => "non-string key"}) + end + end + + # --------------------------------------------------------------------------- + # add_root/2 + # --------------------------------------------------------------------------- + + describe "add_root/2" do + test "adds a CID to roots when it exists in blocks" do + {:ok, {car, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"x" => 1}) + + assert {:ok, updated_car} = DrislCAR.add_root(car, cid) + assert cid in updated_car.roots + end + + test "returns :not_in_blocks when CID is not in blocks" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("ghost", :drisl) + + assert {:error, :not_in_blocks} = DrislCAR.add_root(car, cid) + end + + test "no-ops when the CID is already a root" do + {:ok, {car, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"x" => 1}) + + {:ok, car_with_root} = DrislCAR.add_root(car, cid) + + assert {:ok, ^car_with_root} = DrislCAR.add_root(car_with_root, cid) + assert length(car_with_root.roots) == 1 + end + end + + # --------------------------------------------------------------------------- + # remove_root/2 + # --------------------------------------------------------------------------- + + describe "remove_root/2" do + test "removes a CID from roots" do + {:ok, {car, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"x" => 1}) + + {:ok, car_with_root} = DrislCAR.add_root(car, cid) + updated_car = DrislCAR.remove_root(car_with_root, cid) + + refute cid in updated_car.roots + end + + test "no-ops when CID is not in roots" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("not a root", :drisl) + + assert DrislCAR.remove_root(car, cid) == car + end + end + + # --------------------------------------------------------------------------- + # remove_block/2 + # --------------------------------------------------------------------------- + + describe "remove_block/2" do + test "removes a block that is not a root" do + {:ok, {car, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"data" => 1}) + + assert {:ok, updated_car} = DrislCAR.remove_block(car, cid) + refute Map.has_key?(updated_car.blocks, cid) + end + + test "returns :is_a_root when the CID is a root" do + {:ok, {car, cid}} = + DrislCAR.add_block(%DrislCAR{version: 1, roots: [], blocks: %{}}, %{"data" => 1}) + + {:ok, car_with_root} = DrislCAR.add_root(car, cid) + + assert {:error, :is_a_root} = DrislCAR.remove_block(car_with_root, cid) + end + + test "no-ops when CID is not in blocks" do + car = %DrislCAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("non-existent", :drisl) + + assert {:ok, ^car} = DrislCAR.remove_block(car, cid) + end + end +end diff --git a/test/dasl/car_test.exs b/test/dasl/car_test.exs new file mode 100644 index 0000000..90f6ede --- /dev/null +++ b/test/dasl/car_test.exs @@ -0,0 +1,303 @@ +defmodule DASL.CARTest do + use ExUnit.Case, async: true + + alias DASL.{CAR, CID, DRISL} + alias Varint.LEB128 + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Builds a minimal valid CAR binary from scratch, bypassing the encoder, so + # decoder tests aren't coupled to the encoder's correctness. + defp build_car_binary(roots, blocks) do + {:ok, header_bin} = DRISL.encode(%{"version" => 1, "roots" => roots}) + header = LEB128.encode(byte_size(header_bin)) <> header_bin + + body = + Enum.reduce(blocks, <<>>, fn {%CID{bytes: cid_bytes}, data}, acc -> + length = byte_size(cid_bytes) + byte_size(data) + acc <> LEB128.encode(length) <> cid_bytes <> data + end) + + header <> body + end + + # --------------------------------------------------------------------------- + # Round-trip — encode / decode + # --------------------------------------------------------------------------- + + describe "round-trip" do + test "single block, no roots" do + data = "hello world" + cid = CID.compute(data) + car = %CAR{version: 1, roots: [], blocks: %{cid => data}} + + assert {:ok, encoded} = CAR.encode(car) + assert {:ok, decoded} = CAR.decode(encoded) + + assert decoded.version == 1 + assert decoded.roots == [] + assert Map.fetch!(decoded.blocks, cid) == data + end + + test "multiple blocks with a root" do + data1 = "block one" + data2 = "block two" + cid1 = CID.compute(data1) + cid2 = CID.compute(data2) + car = %CAR{version: 1, roots: [cid1], blocks: %{cid1 => data1, cid2 => data2}} + + assert {:ok, encoded} = CAR.encode(car) + assert {:ok, decoded} = CAR.decode(encoded) + + assert decoded.roots == [cid1] + assert Map.fetch!(decoded.blocks, cid1) == data1 + assert Map.fetch!(decoded.blocks, cid2) == data2 + end + + test "empty blocks" do + car = %CAR{version: 1, roots: [], blocks: %{}} + + assert {:ok, encoded} = CAR.encode(car) + assert {:ok, decoded} = CAR.decode(encoded) + + assert decoded.blocks == %{} + end + end + + # --------------------------------------------------------------------------- + # Decoder — verify: true (default) + # --------------------------------------------------------------------------- + + describe "decode with verify: true (default)" do + test "returns :cid_mismatch when block data has been tampered" do + data = "legitimate data" + cid = CID.compute(data) + tampered = "tampered data!!" + + car_bin = build_car_binary([], [{cid, tampered}]) + + assert {:error, :block, :cid_mismatch} = CAR.decode(car_bin) + end + + test "accepts correct data" do + data = "correct data" + cid = CID.compute(data) + car_bin = build_car_binary([], [{cid, data}]) + + assert {:ok, _} = CAR.decode(car_bin) + end + end + + # --------------------------------------------------------------------------- + # Decoder — verify: false + # --------------------------------------------------------------------------- + + describe "decode with verify: false" do + test "passes tampered block data through without error" do + data = "legitimate data" + cid = CID.compute(data) + tampered = "tampered data!!" + + car_bin = build_car_binary([], [{cid, tampered}]) + + assert {:ok, decoded} = CAR.decode(car_bin, verify: false) + assert Map.fetch!(decoded.blocks, cid) == tampered + end + end + + # --------------------------------------------------------------------------- + # Encoder — verify: true (default) + # --------------------------------------------------------------------------- + + describe "encode with verify: true (default)" do + test "returns :cid_mismatch when a block CID does not match its data" do + wrong_cid = CID.compute("something else entirely") + car = %CAR{version: 1, roots: [], blocks: %{wrong_cid => "this is the actual data"}} + + assert {:error, :block, :cid_mismatch} = CAR.encode(car) + end + + test "accepts blocks where the CID matches the data" do + data = "correct data" + cid = CID.compute(data) + car = %CAR{version: 1, roots: [], blocks: %{cid => data}} + + assert {:ok, _} = CAR.encode(car) + end + end + + # --------------------------------------------------------------------------- + # Encoder — verify: false + # --------------------------------------------------------------------------- + + describe "encode with verify: false" do + test "writes a mismatched CID without error" do + wrong_cid = CID.compute("not this data") + car = %CAR{version: 1, roots: [], blocks: %{wrong_cid => "this is the actual data"}} + + assert {:ok, bin} = CAR.encode(car, verify: false) + assert is_binary(bin) + end + end + + # --------------------------------------------------------------------------- + # Decoder — header errors + # --------------------------------------------------------------------------- + + describe "decode header errors" do + test "zero-length header returns :empty" do + binary = LEB128.encode(0) <> <<>> + assert {:error, :header, :empty} = CAR.decode(binary) + end + + test "header without version key returns :missing_version" do + {:ok, no_version} = DRISL.encode(%{"roots" => []}) + binary = LEB128.encode(byte_size(no_version)) <> no_version + assert {:error, :header, :missing_version} = CAR.decode(binary) + end + + test "header without roots key returns :missing_roots" do + {:ok, no_roots} = DRISL.encode(%{"version" => 1}) + binary = LEB128.encode(byte_size(no_roots)) <> no_roots + assert {:error, :header, :missing_roots} = CAR.decode(binary) + end + + test "header with unsupported version returns :unsupported_version" do + {:ok, bad_version} = DRISL.encode(%{"version" => 2, "roots" => []}) + binary = LEB128.encode(byte_size(bad_version)) <> bad_version + assert {:error, :header, :unsupported_version} = CAR.decode(binary) + end + + test "header with non-CID root returns :invalid_roots" do + {:ok, bad_roots} = DRISL.encode(%{"version" => 1, "roots" => ["not-a-cid"]}) + binary = LEB128.encode(byte_size(bad_roots)) <> bad_roots + assert {:error, :header, :invalid_roots} = CAR.decode(binary) + end + end + + # --------------------------------------------------------------------------- + # Decoder — block errors + # --------------------------------------------------------------------------- + + describe "decode block errors" do + test "block with varint length < 36 returns :too_short" do + {:ok, header_bin} = DRISL.encode(%{"version" => 1, "roots" => []}) + header = LEB128.encode(byte_size(header_bin)) <> header_bin + short_block = LEB128.encode(10) <> :binary.copy(<<0>>, 10) + + assert {:error, :block, :too_short} = CAR.decode(header <> short_block) + end + end + + # --------------------------------------------------------------------------- + # add_block/2 + # --------------------------------------------------------------------------- + + describe "add_block/2" do + test "computes and stores the CID, returns the updated car and CID" do + car = %CAR{version: 1, roots: [], blocks: %{}} + data = "some raw data" + + {updated_car, cid} = CAR.add_block(car, data) + + assert Map.fetch!(updated_car.blocks, cid) == data + assert cid == CID.compute(data, :raw) + end + + test "computed CID verifies against the stored data" do + {updated_car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, "data") + + assert CID.verify?(cid, Map.fetch!(updated_car.blocks, cid)) + end + + test "CID uses :raw codec" do + {_, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, "data") + + assert cid.codec == :raw + end + end + + # --------------------------------------------------------------------------- + # add_root/2 + # --------------------------------------------------------------------------- + + describe "add_root/2" do + test "adds a CID to roots when it exists in blocks" do + data = "block data" + {car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, data) + + assert {:ok, updated_car} = CAR.add_root(car, cid) + assert cid in updated_car.roots + end + + test "returns :not_in_blocks when CID is not in blocks" do + car = %CAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("ghost block") + + assert {:error, :not_in_blocks} = CAR.add_root(car, cid) + end + + test "no-ops when the CID is already a root" do + data = "block data" + {car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, data) + {:ok, car_with_root} = CAR.add_root(car, cid) + + assert {:ok, ^car_with_root} = CAR.add_root(car_with_root, cid) + assert length(car_with_root.roots) == 1 + end + end + + # --------------------------------------------------------------------------- + # remove_root/2 + # --------------------------------------------------------------------------- + + describe "remove_root/2" do + test "removes a CID from roots" do + data = "block data" + {car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, data) + {:ok, car_with_root} = CAR.add_root(car, cid) + + updated_car = CAR.remove_root(car_with_root, cid) + + refute cid in updated_car.roots + end + + test "no-ops when CID is not in roots" do + car = %CAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("not a root") + + assert CAR.remove_root(car, cid) == car + end + end + + # --------------------------------------------------------------------------- + # remove_block/2 + # --------------------------------------------------------------------------- + + describe "remove_block/2" do + test "removes a block that is not a root" do + data = "removable block" + {car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, data) + + assert {:ok, updated_car} = CAR.remove_block(car, cid) + refute Map.has_key?(updated_car.blocks, cid) + end + + test "returns :is_a_root when the CID is a root" do + data = "root block" + {car, cid} = CAR.add_block(%CAR{version: 1, roots: [], blocks: %{}}, data) + {:ok, car_with_root} = CAR.add_root(car, cid) + + assert {:error, :is_a_root} = CAR.remove_block(car_with_root, cid) + end + + test "no-ops when CID is not in blocks" do + car = %CAR{version: 1, roots: [], blocks: %{}} + cid = CID.compute("non-existent") + + assert {:ok, ^car} = CAR.remove_block(car, cid) + end + end +end