Skip to main content

lib/safe_atom.ex

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