From 05cf00400995680bef1fb2fe87173d48a1f51e15 Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Sun, 5 Apr 2026 10:41:11 +1000 Subject: [PATCH] feat: CID struct --- lib/dasl/cid.ex | 297 +++++++++++++++++++++++++++++++++++++++++ lib/dasl/drisl.ex | 7 +- test/dasl/cid_test.exs | 253 +++++++++++++++++++++++++++++++++++ test/dasl_test.exs | 8 -- 4 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 lib/dasl/cid.ex create mode 100644 test/dasl/cid_test.exs delete mode 100644 test/dasl_test.exs diff --git a/lib/dasl/cid.ex b/lib/dasl/cid.ex new file mode 100644 index 0000000..2ae61c1 --- /dev/null +++ b/lib/dasl/cid.ex @@ -0,0 +1,297 @@ +defmodule DASL.CID do + @moduledoc """ + Struct for DASL CIDs. + Spec: https://dasl.ing/cid.html + """ + + use TypedStruct + + @codec_raw 0x55 + @codec_drisl 0x71 + @hash_sha256 0x12 + @hash_size 32 + + @type codec :: :raw | :drisl + + typedstruct enforce: true do + field :version, pos_integer() + field :codec, codec() + field :hash_type, non_neg_integer() + field :hash_size, pos_integer() + field :digest, binary() + field :bytes, binary() + end + + @doc """ + Parses a string-encoded CID into raw bytes. + + Expects the `b` multibase prefix followed by a lowercase RFC 4648 base32-encoded payload. + + ## Examples + + iex> DASL.CID.parse("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + {:ok, <<1, 85, 18, 32, 185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, 215, 218, + 125, 171, 250, 196, 132, 239, 227, 122, 83, 128, 238, 144, 136, 247, 172, + 226, 239, 205, 233>>} + + iex> DASL.CID.parse("zQmInvalidPrefix") + {:error, "CID must start with 'b'"} + + iex> DASL.CID.parse("b!!!!notbase32") + {:error, "invalid base32 encoding"} + + """ + @spec parse(String.t()) :: {:ok, binary()} | {:error, String.t()} + def parse(<<"b", rest::binary>>) do + case Base.decode32(rest, case: :lower, padding: false) do + {:ok, bytes} -> {:ok, bytes} + :error -> {:error, "invalid base32 encoding"} + end + end + + def parse(_), do: {:error, "CID must start with 'b'"} + + @doc """ + Decodes a raw CID bytestring into its constituent fields. + + ## Examples + + iex> bytes = <<1, 85, 18, 32, 185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, 215, + ...> 218, 125, 171, 250, 196, 132, 239, 227, 122, 83, 128, 238, 144, 136, + ...> 247, 172, 226, 239, 205, 233>> + iex> DASL.CID.decode(bytes) + {:ok, %{version: 1, codec: :raw, hash_type: 18, hash_size: 32, + digest: <<185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, 215, 218, 125, + 171, 250, 196, 132, 239, 227, 122, 83, 128, 238, 144, 136, 247, + 172, 226, 239, 205, 233>>}} + + iex> DASL.CID.decode(<<2, 85, 18, 32>> <> :binary.copy(<<0>>, 32)) + {:error, "unsupported CID version: 2"} + + iex> DASL.CID.decode(<<1, 0xAB, 18, 32>> <> :binary.copy(<<0>>, 32)) + {:error, "unsupported codec: 0xAB"} + + iex> DASL.CID.decode(<<1, 85, 0x11, 32>> <> :binary.copy(<<0>>, 32)) + {:error, "unsupported hash type: 0x11"} + + iex> DASL.CID.decode(<<1, 85, 18, 31>> <> :binary.copy(<<0>>, 32)) + {:error, "invalid hash size: 31"} + + """ + @spec decode(binary()) :: {:ok, map()} | {:error, String.t()} + def decode(<<1, codec_byte, @hash_sha256, hash_size, digest::binary>>) + when hash_size == @hash_size and byte_size(digest) == @hash_size do + with {:ok, codec} <- decode_codec(codec_byte) do + {:ok, + %{ + version: 1, + codec: codec, + hash_type: @hash_sha256, + hash_size: @hash_size, + digest: digest + }} + end + end + + def decode(<>) when version != 1, + do: {:error, "unsupported CID version: #{version}"} + + def decode(<<1, codec_byte, _rest::binary>>) when codec_byte not in [@codec_raw, @codec_drisl], + do: {:error, "unsupported codec: 0x#{Integer.to_string(codec_byte, 16)}"} + + def decode(<<1, _codec, hash_type, _rest::binary>>) when hash_type != @hash_sha256, + do: {:error, "unsupported hash type: 0x#{Integer.to_string(hash_type, 16)}"} + + def decode(<<1, _codec, @hash_sha256, hash_size, _rest::binary>>) + when hash_size != @hash_size, + do: {:error, "invalid hash size: #{hash_size}"} + + def decode(_), do: {:error, "malformed CID bytes"} + + @doc """ + Constructs a `DASL.CID` from a string-encoded CID. + + ## Examples + + iex> {:ok, cid} = DASL.CID.new("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + iex> cid.version + 1 + iex> cid.codec + :raw + iex> cid.hash_type + 18 + iex> cid.hash_size + 32 + + iex> DASL.CID.new("not-a-cid") + {:error, "CID must start with 'b'"} + + """ + @spec new(String.t()) :: {:ok, t()} | {:error, String.t()} + def new(cid_string) when is_binary(cid_string) do + with {:ok, bytes} <- parse(cid_string), + {:ok, fields} <- decode(bytes) do + {:ok, + struct!(__MODULE__, + version: fields.version, + codec: fields.codec, + hash_type: fields.hash_type, + hash_size: fields.hash_size, + digest: fields.digest, + bytes: bytes + )} + end + end + + @doc """ + Encodes a `DASL.CID` back to its canonical string form. + + ## Examples + + iex> {:ok, cid} = DASL.CID.new("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + iex> DASL.CID.encode(cid) + "bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e" + + """ + @spec encode(t()) :: String.t() + def encode(%__MODULE__{bytes: bytes}), + do: "b" <> Base.encode32(bytes, case: :lower, padding: false) + + @doc """ + Constructs a `DASL.CID` from a CBOR tag (tag 42). + + CBOR-encoded CIDs have a leading null byte (`0x00`) prepended to the raw CID bytes. + + ## Examples + + iex> {:ok, cid} = DASL.CID.new("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + iex> tag = DASL.CID.to_cbor(cid) + iex> {:ok, decoded} = DASL.CID.from_cbor(tag) + iex> decoded.codec + :raw + + iex> DASL.CID.from_cbor(%CBOR.Tag{tag: 1, value: "not a cid"}) + {:error, "invalid CBOR CID tag"} + + """ + @spec from_cbor(CBOR.Tag.t()) :: {:ok, t()} | {:error, String.t()} + def from_cbor(%CBOR.Tag{ + tag: 42, + value: %CBOR.Tag{tag: :bytes, value: <<0, cid_bytes::binary>>} + }) do + with {:ok, fields} <- decode(cid_bytes) do + {:ok, + struct!(__MODULE__, + version: fields.version, + codec: fields.codec, + hash_type: fields.hash_type, + hash_size: fields.hash_size, + digest: fields.digest, + bytes: cid_bytes + )} + end + end + + def from_cbor(_), do: {:error, "invalid CBOR CID tag"} + + @doc """ + Converts a `DASL.CID` to a CBOR tag (tag 42) with the required leading null byte. + + ## Examples + + iex> {:ok, cid} = DASL.CID.new("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + iex> DASL.CID.to_cbor(cid) + %CBOR.Tag{ + tag: 42, + value: %CBOR.Tag{ + tag: :bytes, + value: <<0, 1, 85, 18, 32, 185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, + 215, 218, 125, 171, 250, 196, 132, 239, 227, 122, 83, 128, 238, + 144, 136, 247, 172, 226, 239, 205, 233>> + } + } + + """ + @spec to_cbor(t()) :: CBOR.Tag.t() + def to_cbor(%__MODULE__{bytes: bytes}), + do: %CBOR.Tag{tag: 42, value: %CBOR.Tag{tag: :bytes, value: <<0, bytes::binary>>}} + + @doc """ + Computes a CID for an arbitrary binary. + + Hashes `data` with SHA-256 and returns a `DASL.CID` with the given codec (defaults to `:raw`). + + ## Examples + + iex> cid = DASL.CID.compute("hello world") + iex> cid.codec + :raw + iex> cid.version + 1 + iex> cid.hash_size + 32 + + iex> cid = DASL.CID.compute("hello world", :drisl) + iex> cid.codec + :drisl + + """ + @spec compute(binary(), codec()) :: t() + def compute(data, codec \\ :raw) when is_binary(data) and codec in [:raw, :drisl] do + digest = :crypto.hash(:sha256, data) + codec_byte = codec_to_byte(codec) + bytes = <<1, codec_byte, @hash_sha256, @hash_size>> <> digest + + struct!(__MODULE__, + version: 1, + codec: codec, + hash_type: @hash_sha256, + hash_size: @hash_size, + digest: digest, + bytes: bytes + ) + end + + @doc """ + Returns `true` if `data` hashes to the digest recorded in `cid`, `false` otherwise. + + Uses a constant-time comparison to avoid timing attacks. + + ## Examples + + iex> cid = DASL.CID.compute("hello world") + iex> DASL.CID.verify?(cid, "hello world") + true + + iex> cid = DASL.CID.compute("hello world") + iex> DASL.CID.verify?(cid, "goodbye world") + false + + """ + @spec verify?(t(), binary()) :: boolean() + def verify?(%__MODULE__{digest: digest}, data) when is_binary(data) do + candidate = :crypto.hash(:sha256, data) + :crypto.hash_equals(digest, candidate) + end + + defp decode_codec(@codec_raw), do: {:ok, :raw} + defp decode_codec(@codec_drisl), do: {:ok, :drisl} + + defp decode_codec(byte), + do: {:error, "unsupported codec: 0x#{Integer.to_string(byte, 16)}"} + + defp codec_to_byte(:raw), do: @codec_raw + defp codec_to_byte(:drisl), do: @codec_drisl +end + +defimpl String.Chars, for: DASL.CID do + def to_string(value), do: DASL.CID.encode(value) +end + +defimpl Inspect, for: DASL.CID do + + def inspect(cid, _opts) do + cid = DASL.CID.encode(cid) + ~s'DASL.CID.new("#{cid}")' + end +end diff --git a/lib/dasl/drisl.ex b/lib/dasl/drisl.ex index 063cca8..645b2b1 100644 --- a/lib/dasl/drisl.ex +++ b/lib/dasl/drisl.ex @@ -30,11 +30,8 @@ defmodule DASL.DRISL do defp remap_cid_tags(term), do: term - # CIDs are always tagged with `42` in DRISL. - # TODO?: stringify them? - # TODO: custom CID struct - defp do_remap(%CBOR.Tag{tag: 42, value: %CBOR.Tag{tag: :bytes, value: <<0, cid::binary>>}}), - do: cid + defp do_remap(%CBOR.Tag{tag: 42} = tag), + do: DASL.CID.from_cbor(tag) defp do_remap(%CBOR.Tag{} = tag), do: tag defp do_remap(%{} = container), do: remap_cid_tags(container) diff --git a/test/dasl/cid_test.exs b/test/dasl/cid_test.exs new file mode 100644 index 0000000..68f6830 --- /dev/null +++ b/test/dasl/cid_test.exs @@ -0,0 +1,253 @@ +defmodule DASL.CIDTest do + use ExUnit.Case, async: true + doctest DASL.CID + + # SHA-256("hello world") + @digest <<185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, 215, 218, 125, 171, 250, 196, 132, 239, + 227, 122, 83, 128, 238, 144, 136, 247, 172, 226, 239, 205, 233>> + + @raw_bytes <<1, 0x55, 0x12, 0x20>> <> @digest + @drisl_bytes <<1, 0x71, 0x12, 0x20>> <> @digest + + @raw_cid "bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e" + @drisl_cid "bafyreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e" + + describe "parse/1" do + test "decodes a valid raw CID string to bytes" do + assert {:ok, @raw_bytes} = DASL.CID.parse(@raw_cid) + end + + test "decodes a valid drisl CID string to bytes" do + assert {:ok, @drisl_bytes} = DASL.CID.parse(@drisl_cid) + end + + test "errors when prefix is not 'b'" do + assert {:error, "CID must start with 'b'"} = DASL.CID.parse("zQmSomeLegacyCID") + end + + test "errors on invalid base32 content" do + assert {:error, "invalid base32 encoding"} = DASL.CID.parse("b!!!!!") + end + + test "errors on empty string" do + assert {:error, "CID must start with 'b'"} = DASL.CID.parse("") + end + end + + describe "decode/1" do + test "decodes raw bytes into fields" do + assert {:ok, fields} = DASL.CID.decode(@raw_bytes) + assert fields.version == 1 + assert fields.codec == :raw + assert fields.hash_type == 0x12 + assert fields.hash_size == 32 + assert fields.digest == @digest + end + + test "decodes drisl bytes into fields" do + assert {:ok, fields} = DASL.CID.decode(@drisl_bytes) + assert fields.codec == :drisl + end + + test "errors on unsupported version" do + bad = <<2, 0x55, 0x12, 0x20>> <> @digest + assert {:error, "unsupported CID version: 2"} = DASL.CID.decode(bad) + end + + test "errors on unsupported codec" do + bad = <<1, 0xAB, 0x12, 0x20>> <> @digest + assert {:error, "unsupported codec: 0xAB"} = DASL.CID.decode(bad) + end + + test "errors on unsupported hash type" do + bad = <<1, 0x55, 0x11, 0x20>> <> @digest + assert {:error, "unsupported hash type: 0x11"} = DASL.CID.decode(bad) + end + + test "errors on wrong hash size" do + bad = <<1, 0x55, 0x12, 0x10>> <> @digest + assert {:error, "invalid hash size: 16"} = DASL.CID.decode(bad) + end + + test "errors on truncated digest" do + bad = <<1, 0x55, 0x12, 0x20>> <> binary_part(@digest, 0, 16) + assert {:error, "malformed CID bytes"} = DASL.CID.decode(bad) + end + + test "errors on empty binary" do + assert {:error, "malformed CID bytes"} = DASL.CID.decode(<<>>) + end + end + + describe "new/1" do + test "constructs a CID struct from a raw CID string" do + assert {:ok, cid} = DASL.CID.new(@raw_cid) + assert %DASL.CID{} = cid + assert cid.version == 1 + assert cid.codec == :raw + assert cid.hash_type == 0x12 + assert cid.hash_size == 32 + assert cid.digest == @digest + assert cid.bytes == @raw_bytes + end + + test "constructs a CID struct from a drisl CID string" do + assert {:ok, cid} = DASL.CID.new(@drisl_cid) + assert cid.codec == :drisl + assert cid.bytes == @drisl_bytes + end + + test "propagates parse errors" do + assert {:error, "CID must start with 'b'"} = DASL.CID.new("not-a-cid") + end + + test "propagates decode errors" do + bad_bytes = <<2, 0x55, 0x12, 0x20>> <> @digest + bad_str = "b" <> Base.encode32(bad_bytes, case: :lower, padding: false) + assert {:error, "unsupported CID version: 2"} = DASL.CID.new(bad_str) + end + end + + describe "encode/1" do + test "round-trips a raw CID" do + {:ok, cid} = DASL.CID.new(@raw_cid) + assert DASL.CID.encode(cid) == @raw_cid + end + + test "round-trips a drisl CID" do + {:ok, cid} = DASL.CID.new(@drisl_cid) + assert DASL.CID.encode(cid) == @drisl_cid + end + + test "always starts with 'b'" do + {:ok, cid} = DASL.CID.new(@raw_cid) + assert String.starts_with?(DASL.CID.encode(cid), "b") + end + end + + describe "to_cbor/1" do + test "wraps bytes in a tag-42 CBOR tag with null-byte prefix" do + {:ok, cid} = DASL.CID.new(@raw_cid) + tag = DASL.CID.to_cbor(cid) + assert %CBOR.Tag{tag: 42, value: %CBOR.Tag{tag: :bytes, value: <<0, rest::binary>>}} = tag + assert rest == @raw_bytes + end + end + + describe "from_cbor/1" do + test "decodes a tag-42 CBOR CID into a struct" do + {:ok, cid} = DASL.CID.new(@raw_cid) + tag = DASL.CID.to_cbor(cid) + assert {:ok, decoded} = DASL.CID.from_cbor(tag) + assert %DASL.CID{} = decoded + assert decoded.codec == :raw + assert decoded.digest == @digest + assert decoded.bytes == @raw_bytes + end + + test "round-trips through CBOR and back to the same CID string" do + {:ok, cid} = DASL.CID.new(@raw_cid) + {:ok, decoded} = cid |> DASL.CID.to_cbor() |> DASL.CID.from_cbor() + assert DASL.CID.encode(decoded) == @raw_cid + end + + test "errors on wrong CBOR tag number" do + tag = %CBOR.Tag{tag: 1, value: %CBOR.Tag{tag: :bytes, value: <<0>> <> @raw_bytes}} + assert {:error, "invalid CBOR CID tag"} = DASL.CID.from_cbor(tag) + end + + test "errors when null byte prefix is missing" do + tag = %CBOR.Tag{tag: 42, value: %CBOR.Tag{tag: :bytes, value: @raw_bytes}} + assert {:error, _} = DASL.CID.from_cbor(tag) + end + + test "errors on non-tag input" do + assert {:error, "invalid CBOR CID tag"} = DASL.CID.from_cbor("not a tag") + end + end + + describe "compute/2" do + test "returns a valid CID struct" do + cid = DASL.CID.compute("hello world") + assert %DASL.CID{} = cid + assert cid.version == 1 + assert cid.hash_type == 0x12 + assert cid.hash_size == 32 + end + + test "defaults to :raw codec" do + cid = DASL.CID.compute("hello world") + assert cid.codec == :raw + end + + test "accepts :drisl codec" do + cid = DASL.CID.compute("hello world", :drisl) + assert cid.codec == :drisl + end + + test "digest matches SHA-256 of input" do + cid = DASL.CID.compute("hello world") + assert cid.digest == @digest + end + + test "produces the correct known CID string" do + cid = DASL.CID.compute("hello world") + assert DASL.CID.encode(cid) == @raw_cid + end + + test "raw and drisl CIDs for the same data have different bytes" do + raw = DASL.CID.compute("hello world", :raw) + drisl = DASL.CID.compute("hello world", :drisl) + assert raw.bytes != drisl.bytes + assert raw.digest == drisl.digest + end + + test "different inputs produce different CIDs" do + a = DASL.CID.compute("foo") + b = DASL.CID.compute("bar") + assert a.digest != b.digest + end + + test "round-trips through encode and new" do + cid = DASL.CID.compute("hello world") + {:ok, decoded} = cid |> DASL.CID.encode() |> DASL.CID.new() + assert decoded.digest == cid.digest + assert decoded.codec == cid.codec + end + end + + describe "verify?/2" do + test "returns true when data matches the CID" do + cid = DASL.CID.compute("hello world") + assert DASL.CID.verify?(cid, "hello world") + end + + test "returns false when data does not match" do + cid = DASL.CID.compute("hello world") + refute DASL.CID.verify?(cid, "goodbye world") + end + + test "returns false for empty binary against non-empty CID" do + cid = DASL.CID.compute("hello world") + refute DASL.CID.verify?(cid, "") + end + + test "returns true for empty binary when CID was computed from empty binary" do + cid = DASL.CID.compute("") + assert DASL.CID.verify?(cid, "") + end + + test "codec does not affect verification — only the digest matters" do + raw_cid = DASL.CID.compute("hello world", :raw) + drisl_cid = DASL.CID.compute("hello world", :drisl) + assert DASL.CID.verify?(raw_cid, "hello world") + assert DASL.CID.verify?(drisl_cid, "hello world") + end + + test "works with a CID decoded from a string" do + {:ok, cid} = DASL.CID.new(@raw_cid) + assert DASL.CID.verify?(cid, "hello world") + refute DASL.CID.verify?(cid, "not hello world") + end + end +end diff --git a/test/dasl_test.exs b/test/dasl_test.exs deleted file mode 100644 index 1cdfdb9..0000000 --- a/test/dasl_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule DASLTest do - use ExUnit.Case - doctest DASL - - test "greets the world" do - assert DASL.hello() == :world - end -end -- 2.51.2