defmodule Digester do @moduledoc """ This module represents a utility to calculate a digest deterministically from a set of values. If the order of the elements or their values change, the hash will change. This module is useful to support incremental localization based on the hash of the content and the context it depends on. """ defstruct [:digestables, :algorithm] @type t :: %__MODULE__{} @type new_opts :: [{:algorithm, atom()}] @doc """ Creates a new digester with the given options. ## Options - `:algorithm` - The algorithm to use for hashing. Defaults to `:sha256`. ## Examples iex> digester = Digester.new() %Digester{algorithm: :sha256, digestables: []} """ @spec new(opts :: new_opts) :: __MODULE__.t() def new(opts \\ []) do algorithm = Keyword.get(opts, :algorithm, :sha256) %__MODULE__{digestables: [], algorithm: algorithm} end @doc """ Combines the given digestable with the current digester. Note that the **order** of the digestables is important. If the same digestables are combined but in a different order, the resulting digest will be different. """ @spec combine(digester :: __MODULE__.t(), digestable :: atom | binary | struct | number | tuple | map) :: __MODULE__.t() def combine(digester, digestable) @spec combine(digester :: __MODULE__.t(), digestable :: binary) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_binary(digestable) do %{digester | digestables: [hash(digestable, digester.algorithm) | digester.digestables]} end @spec combine(digester :: __MODULE__.t(), digestable :: atom) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_boolean(digestable) do boolean_string = if digestable, do: "1", else: "0" %{digester | digestables: [hash(boolean_string, digester.algorithm) | digester.digestables]} end @spec combine(digester :: __MODULE__.t(), digestable :: number) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_number(digestable) do %{digester | digestables: [hash("#{digestable}", digester.algorithm) | digester.digestables]} end @spec combine(digester :: __MODULE__.t(), digestable :: list) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_list(digestable) do digestable |> Enum.reduce(digester, fn digestable, digester -> combine(digester, digestable) end) end @spec combine(digester :: __MODULE__.t(), digestable :: tuple) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_tuple(digestable) do combine(digester, digestable |> Tuple.to_list()) end @spec combine(digester :: __MODULE__.t(), digestable :: map) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_atom(digestable) do combine(digester, digestable |> Atom.to_string()) end @spec combine(digester :: __MODULE__.t(), digestable :: map) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_map(digestable) do combine(digester, digestable |> Map.to_list() |> Enum.sort()) end @spec combine(digester :: __MODULE__.t(), digestable :: struct) :: __MODULE__.t() def combine(%__MODULE__{} = digester, digestable) when is_struct(digestable) do combine(digester, digestable |> Map.to_list() |> Enum.sort()) end @doc """ It finalizes the digester and returns the digest as a string. ## Examples iex> digester = Digester.new() |> Digester.combine("Hello") |> Digester.combine("World") %Digester{algorithm: :sha256, digestables: ["e4b7fbbd1e5b4bae1e4b7fbbd1e5b4bae"]} iex> Digester.finalize(digester) "e4b7fbbd1e5b4bae1e4b7fbbd1e5b4bae" """ @spec finalize(digester :: __MODULE__.t()) :: String.t() def finalize(%__MODULE__{} = digester) do digester.digestables |> Enum.join("|") |> hash(digester.algorithm) end defp hash(value, algorithm) when is_binary(value) do :crypto.hash(algorithm, value) |> Base.encode16() |> String.downcase() end end