lib/gpg_ex.ex

defmodule GPGex do
  @moduledoc """
  A simple wrapper to run GPG commands.

  Tested on Linux with `gpg (GnuPG) 2.2.27`.

  > ### Warning {: .warning}
  >
  > This is a pre-release version. As such, anything _may_ change
  > at any time, the public API _should not_ be considered stable,
  > and using a pinned version is _recommended_.

  ## Configuration

  You can specify an optional directory to be passed to `--homedir`.
  Without this option your default GPG keyring will be used.

      config :gpg_ex,
        gpg_home: "/tmp/gpg_ex_home"

  """

  @base_args ["--batch", "--status-fd=1"]

  @doc ~S"""
  Runs GPG with the given args.

  Returns parsed status messages and stdout rows separately in a tuple.

  ## Examples

      iex> {:ok, messages, stdout} = GPGex.cmd(["--recv-keys", "18D5DCA13E5D61587F552A1BDEB5A837B34DD01D"])
      iex> messages
      [
        "KEY_CONSIDERED 18D5DCA13E5D61587F552A1BDEB5A837B34DD01D 0",
        "IMPORTED DEB5A837B34DD01D GPGEx Test <spam@sherlox.io>",
        "IMPORT_OK 1 18D5DCA13E5D61587F552A1BDEB5A837B34DD01D",
        "IMPORT_RES 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0"
      ]
      iex> stdout
      [
        "key DEB5A837B34DD01D: public key \"GPGEx Test <spam@sherlox.io>\" imported",
        "Total number processed: 1",
        "imported: 1"
      ]

      iex> GPGex.cmd(["--delete-keys", "18D5DCA13E5D61587F552A1BDEB5A837B34DD01D"])
      {:ok, [], []}

      iex> GPGex.cmd(["--recv-keys", "91C8AFC4674BF0963E7A90CEB7FFBE9D2DF23D67"])
      {:error, ["FAILURE recv-keys 167772218"], ["keyserver receive failed: No data"]}

  """
  @spec cmd([String.t()]) :: {:ok | :error, [String.t()], [String.t()]}
  def cmd(args) when is_list(args) do
    {res, code} =
      System.cmd(
        "gpg",
        base_args() ++ args,
        into: [],
        stderr_to_stdout: true
      )

    [stdout, messages] = process_output(res)

    case code do
      0 -> {:ok, stdout, messages}
      _ -> {:error, stdout, messages}
    end
  end

  @doc """
  Same as `cmd/1` but raises a
  `RuntimeError` if the command fails.
  """
  @spec cmd!([String.t()]) :: {[String.t()], [String.t()]}
  def cmd!(args) when is_list(args) do
    case cmd(args) do
      {:ok, stdout, messages} ->
        {stdout, messages}

      {:error, stdout, _} ->
        raise RuntimeError,
              "GPG command failed with: #{stdout}"
    end
  end

  defp process_output(stdout) do
    stdout
    |> Enum.join()
    |> String.split("\n", trim: true)
    |> Enum.split_with(&String.starts_with?(&1, "[GNUPG:]"))
    |> Tuple.to_list()
    |> Enum.map(&cleanup_lines(&1))
  end

  defp cleanup_lines(lines) do
    lines
    |> Enum.map(fn line -> String.replace(line, ~r/(\[GNUPG:\]|gpg:)\s/, "") |> String.trim() end)
  end

  defp base_args() do
    case Application.fetch_env(:gpg_ex, :gpg_home) do
      {:ok, gpg_home} -> ["--homedir", gpg_home] ++ @base_args
      _ -> @base_args
    end
  end
end