defmodule SafeAtom do @moduledoc """ Safe, whitelist-based casting of values to atoms. `SafeAtom` returns only atoms that are explicitly present in the `:allowed` list. Binary input is never converted with `String.to_atom/1` or `String.to_existing_atom/1`. Instead, binary values are compared with the string representation of atoms already present in `:allowed`. This avoids creating new atoms from external input and avoids querying the VM atom table for arbitrary binary values. ## Examples iex> SafeAtom.cast("user", allowed: [:user, :guest]) {:ok, :user} iex> SafeAtom.cast(:user, allowed: [:user, :guest]) {:ok, :user} iex> SafeAtom.cast("admin", allowed: [:user, :guest]) {:error, :not_allowed} iex> SafeAtom.cast(:admin, allowed: [:user, :guest]) {:error, :not_allowed} iex> SafeAtom.cast("anything", allowed: []) {:error, :not_allowed} iex> SafeAtom.cast("user", []) {:error, :missing_allowed} iex> SafeAtom.cast("user", allowed: ["user"]) {:error, :invalid_allowed} iex> SafeAtom.cast(123, allowed: [:user]) {:error, :invalid_value} iex> SafeAtom.cast(nil, allowed: [:user]) {:error, :not_allowed} iex> SafeAtom.cast(nil, allowed: [nil]) {:ok, nil} ## Error reasons * `:missing_allowed` - the `:allowed` option was not provided. * `:invalid_allowed` - `:allowed` is not a list of atoms. * `:invalid_value` - the input value is not a binary or an atom. * `:not_allowed` - the input value is valid, but does not match any allowed atom. ## Telemetry events `SafeAtom` emits the following [Telemetry](https://hexdocs.pm/telemetry/) events: ### `[:safe_atom, :cast, :rejected]` Emitted whenever `cast/2` returns `{:error, reason}`. * **measurements**: * `:system_time` - `System.system_time/0` at the moment of rejection * **metadata**: * `:reason` - one of `:missing_allowed`, `:invalid_allowed`, `:invalid_value`, or `:not_allowed` * `:value` - the input value passed to `cast/2` * `:allowed` - the `:allowed` option value, or `nil` when the option is missing """ @type reason :: :missing_allowed | :invalid_allowed | :invalid_value | :not_allowed @doc """ Casts a binary or atom to one of the explicitly allowed atoms. The `:allowed` option is required and must be a list of atoms. For binary input, `SafeAtom` compares the input with `Atom.to_string/1` for each allowed atom. The returned atom is always taken from the `:allowed` list. Returns `{:error, :missing_allowed}` when called with options that do not contain `:allowed`. ## Examples iex> SafeAtom.cast("user", allowed: [:user, :guest]) {:ok, :user} iex> SafeAtom.cast(:guest, allowed: [:user, :guest]) {:ok, :guest} iex> SafeAtom.cast("admin", allowed: [:user, :guest]) {:error, :not_allowed} iex> SafeAtom.cast(:admin, allowed: [:user, :guest]) {:error, :not_allowed} iex> SafeAtom.cast("user", allowed: []) {:error, :not_allowed} iex> SafeAtom.cast("user", []) {:error, :missing_allowed} iex> SafeAtom.cast("user", allowed: :user) {:error, :invalid_allowed} iex> SafeAtom.cast("user", allowed: [:user, "guest"]) {:error, :invalid_allowed} iex> SafeAtom.cast(123, allowed: [:user]) {:error, :invalid_value} iex> SafeAtom.cast(nil, allowed: [nil]) {:ok, nil} """ @spec cast(term(), keyword()) :: {:ok, atom()} | {:error, reason()} def cast(value, allowed: allowed) do cond do not is_list(allowed) -> reject(value, :invalid_allowed, allowed) not Enum.all?(allowed, &is_atom/1) -> reject(value, :invalid_allowed, allowed) not is_binary(value) and not is_atom(value) -> reject(value, :invalid_value, allowed) true -> case find_allowed(value, allowed) do {:ok, allowed_atom} -> {:ok, allowed_atom} :error -> reject(value, :not_allowed, allowed) end end end @spec cast(term(), term()) :: {:error, :missing_allowed} def cast(value, _opts), do: reject(value, :missing_allowed, nil) @spec cast!(term(), keyword()) :: atom() def cast!(value, opts) do case cast(value, opts) do {:ok, atom} -> atom {:error, reason} -> raise SafeAtom.Error, value: value, reason: reason, allowed: allowed_from_opts(opts) end end @spec find_allowed(atom(), [atom()]) :: {:ok, atom()} | :error defp find_allowed(value, allowed) when is_atom(value) do if value in allowed do {:ok, value} else :error end end @spec find_allowed(binary(), [atom()]) :: {:ok, atom()} | :error defp find_allowed(value, allowed) when is_binary(value) do Enum.find_value(allowed, :error, fn allowed_atom -> if Atom.to_string(allowed_atom) == value do {:ok, allowed_atom} end end) end @spec allowed_from_opts(term()) :: term() defp allowed_from_opts(opts) when is_list(opts), do: Keyword.get(opts, :allowed) defp allowed_from_opts(_opts), do: nil @spec reject(term(), reason(), term()) :: {:error, reason()} defp reject(value, reason, allowed) do :telemetry.execute( [:safe_atom, :cast, :rejected], %{system_time: System.system_time()}, %{reason: reason, value: value, allowed: allowed} ) {:error, reason} end end