defmodule Aether.ATProto.CID do @moduledoc """ Content Identifier (CID) handling for ATProto. Supports CIDv0 and CIDv1 formats commonly used in IPFS and ATProto networks. """ defstruct [:version, :codec, :hash, :multibase, :multihash] @type t :: %__MODULE__{ version: 0 | 1, codec: String.t() | nil, hash: String.t(), multibase: String.t() | nil, multihash: binary() | nil } defmodule ParseError do defexception message: "Invalid CID format" end @cid_v0_length 46 @cid_v0_prefix "Qm" @cid_v1_base32_prefix "b" @cid_v1_base58_prefix "z" @doc """ Parse a CID string into structured data. ## Examples iex> Aether.ATProto.CID.parse_cid("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") {:ok, %Aether.ATProto.CID{version: 0, codec: "dag-pb", hash: "QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU", multibase: "base58btc"}} iex> Aether.ATProto.CID.parse_cid("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") {:ok, %Aether.ATProto.CID{version: 1, codec: "dag-cbor", hash: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", multibase: "base32"}} """ def parse_cid(cid_string) when is_binary(cid_string) do cond do # CIDv0 - Base58BTC SHA-256, always 46 characters starting with Qm String.starts_with?(cid_string, @cid_v0_prefix) and byte_size(cid_string) == @cid_v0_length -> {:ok, %__MODULE__{ version: 0, codec: "dag-pb", hash: cid_string, multibase: "base58btc" }} # CIDv1 - Base32 encoded (must have proper base32 characters after 'b') String.starts_with?(cid_string, @cid_v1_base32_prefix) and valid_base32_cid?(cid_string) -> {:ok, %__MODULE__{ version: 1, codec: "dag-cbor", hash: cid_string, multibase: "base32" }} # CIDv1 - Base58BTC encoded (must have proper base58 characters after 'z') String.starts_with?(cid_string, @cid_v1_base58_prefix) and valid_base58_cid?(cid_string) -> {:ok, %__MODULE__{ version: 1, codec: "dag-cbor", hash: cid_string, multibase: "base58btc" }} true -> {:error, :invalid_format} end end # Helper function to validate CIDv1 base32 format defp valid_base32_cid?(cid_string) do # Must be longer than just the prefix # Rest of string should be valid base32 characters (a-z2-7) byte_size(cid_string) > 1 and String.match?(String.slice(cid_string, 1..-1//1), ~r/^[a-z2-7]+$/) end # Helper function to validate CIDv1 base58 format defp valid_base58_cid?(cid_string) do # Must be longer than just the prefix # Get substring starting after the prefix byte_size(cid_string) > 1 and String.slice(cid_string, 1..-1//1) # Use a more permissive base58 regex that allows all common base58 characters |> String.match?(~r/^[1-9A-Za-z]*$/) end def parse_cid!(cid_string) when is_binary(cid_string) do case parse_cid(cid_string) do {:ok, cid} -> cid {:error, _} -> raise ParseError, "Failed to parse CID: #{cid_string}" end end def parse_cid!(%__MODULE__{} = cid), do: cid def parse_cid!(_other), do: raise(ParseError) @doc """ Check if a value is a valid CID. ## Examples iex> Aether.ATProto.CID.valid_cid?("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") true iex> Aether.ATProto.CID.valid_cid?("invalid") false iex> Aether.ATProto.CID.valid_cid?(%Aether.ATProto.CID{version: 1, hash: "bafy..."}) true """ def valid_cid?(cid) when is_binary(cid) do match?({:ok, _}, parse_cid(cid)) end def valid_cid?(%__MODULE__{}), do: true def valid_cid?(_), do: false @doc """ Convert a CID struct back to its string representation. ## Examples iex> cid = %Aether.ATProto.CID{hash: "QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU"} iex> Aether.ATProto.CID.cid_to_string(cid) "QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU" """ def cid_to_string(%__MODULE__{hash: hash}), do: hash def cid_to_string(cid_string) when is_binary(cid_string), do: cid_string @doc """ Encode a CID to its string representation. Alias for cid_to_string/1. """ def encode(%__MODULE__{hash: hash} = cid) when is_binary(hash) do # If hash is already a string (like "bafyreie..."), use it # If hash is binary bytes, encode to base32 if String.printable?(hash) do cid_to_string(cid) else # Convert binary hash to base32 string for CIDv1 encoded_hash = Base.encode32(hash, case: :lower, padding: false) "b" <> encoded_hash end end def encode(cid_string) when is_binary(cid_string), do: cid_string @doc """ Decode a CID string. Alias for parse_cid/1. """ def decode(cid_string) when is_binary(cid_string), do: parse_cid(cid_string) @doc """ Extract the version from a CID. ## Examples iex> Aether.ATProto.CID.cid_version("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") 0 iex> Aether.ATProto.CID.cid_version("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") 1 iex> Aether.ATProto.CID.cid_version("invalid") {:error, :invalid_cid} """ def cid_version(cid_string) when is_binary(cid_string) do case parse_cid(cid_string) do {:ok, %__MODULE__{version: version}} -> version {:error, _} -> {:error, :invalid_cid} end end def cid_version(%__MODULE__{version: version}), do: version @doc """ Get the codec used by the CID. ## Examples iex> Aether.ATProto.CID.cid_codec("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") "dag-pb" iex> Aether.ATProto.CID.cid_codec("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") "dag-cbor" """ def cid_codec(cid) when is_binary(cid) do case parse_cid(cid) do {:ok, %__MODULE__{codec: codec}} -> codec {:error, _} -> {:error, :invalid_cid} end end def cid_codec(%__MODULE__{codec: codec}), do: codec @doc """ Check if a CID is in CIDv0 format. ## Examples iex> Aether.ATProto.CID.is_cidv0?("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") true iex> Aether.ATProto.CID.is_cidv0?("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") false """ def is_cidv0?(cid) when is_binary(cid) do case cid_version(cid) do 0 -> true _ -> false end end def is_cidv0?(%__MODULE__{version: 0}), do: true def is_cidv0?(_), do: false @doc """ Check if a CID is in CIDv1 format. ## Examples iex> Aether.ATProto.CID.is_cidv1?("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") true iex> Aether.ATProto.CID.is_cidv1?("QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU") false """ def is_cidv1?(cid) when is_binary(cid) do case cid_version(cid) do 1 -> true _ -> false end end def is_cidv1?(%__MODULE__{version: 1}), do: true def is_cidv1?(_), do: false @doc """ Create a new CID struct with the given properties. ## Examples iex> Aether.ATProto.CID.new(1, "dag-cbor", "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") %Aether.ATProto.CID{ version: 1, codec: "dag-cbor", hash: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", multibase: "base32" } """ def new(version, codec, hash) when version in [0, 1] and is_binary(hash) do multibase = if version == 0, do: "base58btc", else: detect_multibase(hash) %__MODULE__{ version: version, codec: codec, hash: hash, multibase: multibase } end defp detect_multibase(hash) do cond do String.starts_with?(hash, @cid_v1_base32_prefix) -> "base32" String.starts_with?(hash, @cid_v1_base58_prefix) -> "base58btc" true -> nil end end @doc """ Convert between CID versions (placeholder - in real implementation would require multihash decoding). Note: This is a simplified version that just changes the struct representation. """ def convert_version(%__MODULE__{version: current_version} = cid, target_version) when current_version in [0, 1] and target_version in [0, 1] do if current_version == target_version do cid else %__MODULE__{cid | version: target_version} end end @doc """ Convert a CID to its binary representation. ## Examples iex> {:ok, cid} = Aether.ATProto.CID.parse_cid("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454") iex> bytes = Aether.ATProto.CID.cid_to_bytes(cid) iex> is_binary(bytes) true """ @spec cid_to_bytes(t()) :: binary() def cid_to_bytes(%__MODULE__{hash: hash}) do # Simplified: just use the hash string as bytes # In production, this would properly encode the CID hash end @doc """ Parse a CID from binary data. Returns `{:ok, cid, rest}` where rest is the remaining binary data. ## Examples iex> {:ok, cid} = Aether.ATProto.CID.parse_cid("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454") iex> bytes = Aether.ATProto.CID.cid_to_bytes(cid) iex> {:ok, parsed, <<>>} = Aether.ATProto.CID.parse_cid_bytes(bytes) iex> parsed.hash == cid.hash true """ @spec parse_cid_bytes(binary()) :: {:ok, t(), binary()} | {:error, term()} def parse_cid_bytes(binary) when is_binary(binary) do # Simplified CID parsing from bytes # Try to parse as a CID string first # In production, this would parse the binary CID format case parse_cid(binary) do {:ok, cid} -> {:ok, cid, <<>>} {:error, _} -> {:error, :invalid_cid} end end end # CBOR Encoder implementation for CID # This allows CIDs to be encoded in CBOR format for ATProto if Code.ensure_loaded?(CBOR.Encoder) do defimpl CBOR.Encoder, for: Aether.ATProto.CID do def encode_into(cid, acc) do # Encode CID as its string representation # ATProto expects CIDs to be strings in CBOR cid_string = Aether.ATProto.CID.cid_to_string(cid) CBOR.Encoder.encode_into(cid_string, acc) end end end