lib/baobab.ex

defmodule Baobab do
  @moduledoc """
  Baobab is a pure Elixir implementation of the 
  [Bamboo](https://github.com/AljoschaMeyer/bamboo) append-only log.

  It is fairly opinionated about the DETS persistence of the logs.
  They are considered to be a spool of the logs as retreived.

  Consumers of this library may wish to place a local copy of the logs in
  a store with better indexing and query properties.

  ### Configuration

  config :baobab, spool_dir: "/tmp"

  ### Options

  - `format`: `:entry` or `:binary`, default: `:entry`
  - `log_id`: the author's log identifier, default `0`
  - `revalidate`: confirm the store contents are unchanged, default: `false`
  - `replace`: rewrite log contents even if it exists, default: `false`
  """

  BaseX.prepare_module(
    "Base62",
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
    32
  )

  defp parse_options(opts) do
    {Keyword.get(opts, :format, :entry), Keyword.get(opts, :log_id, 0),
     Keyword.get(opts, :revalidate, false), Keyword.get(opts, :replace, false)}
  end

  @doc """
  Create and store a new log entry for a stored identity
  """
  def append_log(payload, identity, options \\ []) do
    {_, log_id, _, _} = parse_options(options)
    Baobab.Entry.create(payload, identity, log_id)
  end

  @doc """
  Compact log contents to only items in the certificate pool for
  the latest entry.  This allows validation while reducing space used
  """
  def compact(author, options \\ []) do
    a = author |> b62identity
    opts = parse_options(options)

    case all_seqnum(a, options) do
      [] ->
        []

      entries ->
        last = List.last(entries)
        pool = certificate_pool(a, last, opts) |> MapSet.new()
        eset = entries |> MapSet.new()

        for e <- MapSet.difference(eset, pool) do
          {Baobab.Entry.delete(a, e, opts), e}
        end
    end
  end

  @doc """
  Import and store a list of log entries from their binary format.
  """
  @spec import([binary]) :: [%Baobab.Entry{} | :error]
  def import(binaries, opts \\ [])

  def import(binaries, opts) when is_list(binaries) do
    {_, _, _, overwrite} = parse_options(opts)
    do_import(binaries, overwrite, [])
  end

  def import(_, _), do: [:error]
  defp do_import([], _, acc), do: Enum.reverse(acc)

  defp do_import([binary | rest], overwrite, acc) do
    entry = binary |> Baobab.Entry.from_binary(false) |> Baobab.Entry.store(overwrite)
    do_import(rest, overwrite, [entry | acc])
  end

  @doc """
  Retrieve the latest entry.

  Includes the available certificate pool for its verification.
  """
  def latest_log(author, options \\ []) do
    author |> b62identity |> log_at(max_seqnum(author, options), options)
  end

  @doc """
  Retrieve an author log at a particular sequence number.

  Includes the available certificate pool for its verification.
  """
  def log_at(author, seq, options \\ []) do
    ak = author |> b62identity
    opts = parse_options(options)

    certificate_pool(ak, seq, opts)
    |> Enum.reverse()
    |> Enum.map(fn n -> Baobab.Entry.retrieve(ak, n, opts) end)
  end

  @doc """
  Retrieve all available entries in a particular log
  """
  def full_log(author, options \\ []) do
    opts = parse_options(options)
    author |> b62identity |> gather_all_entries(opts, max_seqnum(author, options), [])
  end

  defp gather_all_entries(_, _, 0, acc), do: acc

  defp gather_all_entries(author, opts, n, acc) do
    newacc =
      case Baobab.Entry.retrieve(author, n, opts) do
        :error -> acc
        entry -> [entry | acc]
      end

    gather_all_entries(author, opts, n - 1, newacc)
  end

  @doc false
  def certificate_pool(author, seq, {_, log_id, _, _}) do
    max = max_seqnum(author, log_id: log_id)
    seq |> Lipmaa.cert_pool() |> Enum.reject(fn n -> n > max end)
  end

  @doc """
  Retrieve the latest sequence number on a particular log identified by the
  author key and log number
  """
  def max_seqnum(author, options \\ []) do
    case all_seqnum(author, options) |> List.last() do
      nil -> 0
      max -> max
    end
  end

  @doc """
  Retrieve the list of  sequence numbers on a particular log identified by the
  author key and log number
  """
  def all_seqnum(author, options \\ []) do
    auth = author |> b62identity

    {_, log_id, _, _} = parse_options(options)

    :content
    |> spool(:foldl, fn item, acc ->
      case item do
        {{^auth, ^log_id, e}, _} -> [e | acc]
        _ -> acc
      end
    end)
    |> Enum.sort()
  end

  @doc """
  Retrieve the latest entry on a particular log identified by the
  author key and log number
  """
  def max_entry(author, options \\ [])

  def max_entry(author, options) do
    opts = parse_options(options)
    author |> b62identity |> Baobab.Entry.retrieve(max_seqnum(author, options), opts)
  end

  @doc """
  Create and store a new identity
  """
  # Maybe make it possible to provide secret ket or both
  # No overwiting? Error handling?
  def create_identity(identity) do
    {_secret, public} = pair = Ed25519.generate_key_pair()
    spool(:identity, :put, {identity, pair})
    public |> b62identity
  end

  @doc """
  A list of {author, log_id, max_seqnum} tuples in the configured store
  """
  # This is all crazy inefficient, but I will clean it up at some
  # point in the future if I care enough.
  def stored_info(), do: stored_info(logs(), [])

  defp stored_info([], acc), do: acc |> Enum.sort()

  defp stored_info([{a, l} | rest], acc) do
    a =
      case max_seqnum(a, log_id: l) do
        0 -> acc
        n -> [{a, l, n} | acc]
      end

    stored_info(rest, a)
  end

  defp logs do
    :content
    |> spool(:foldl, fn item, acc ->
      case item do
        {{a, l, _}, _} -> [{a, l} | acc]
        _ -> acc
      end
    end)
    |> Enum.uniq()
  end

  @doc """
  Retrieve the key for a stored identity.

  Can be either the `:public` or `:secret` key
  """
  def identity_key(identity, which) do
    case spool(:identity, :get, identity) do
      {secret, public} ->
        case which do
          :secret -> secret
          :public -> public
          _ -> :error
        end

      _ ->
        :error
    end
  end

  @doc false
  def spool(which, action, value \\ nil) do
    {:ok, ^which} =
      :dets.open_file(which, file: proper_db_path(which), ram_file: true, auto_save: 30091)

    retval = spool_act(which, action, value)
    :dets.close(which)
    retval
  end

  defp spool_act(which, :get, key) do
    case :dets.lookup(which, key) do
      [{^key, val} | _] -> val
      [] -> nil
    end
  end

  defp spool_act(which, :foldl, fun), do: :dets.foldl(fun, [], which)
  defp spool_act(which, :delete, key), do: :dets.delete(which, key)
  defp spool_act(which, :put, kv), do: :dets.insert(which, kv)

  defp proper_db_path(which) do
    file = Atom.to_string(which) <> ".dets"
    dir = Application.fetch_env!(:baobab, :spool_dir) |> Path.expand()
    Path.join([dir, file]) |> to_charlist
  end

  @doc """
  Resolve an identity to its Base62 representation
  """
  # Looks like a base62-encoded key
  def b62identity(author) when byte_size(author) == 43, do: author
  # Looks like a proper key
  def b62identity(author) when byte_size(author) == 32, do: BaseX.Base62.encode(author)
  # I guess it's a stored identity?
  def b62identity(author) do
    case identity_key(author, :public) do
      :error -> raise "Cannot resolve author: " <> author
      key -> BaseX.Base62.encode(key)
    end
  end
end