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