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 configure the library with both the `global_keystore` key in the configuration and the `keystore` option in functions. The `keystore` option in functions takes precedence over the `global_keystore` configuration, and if none of these are provided, your default GPG keystore (i.e. "homedir") will be used. You can specify an optional directory to be passed to `--homedir`, and an optional filename to be passed to `--keyring` (in which case `--no-default-keyring` will be automatically added). config :gpg_ex, global_keystore: %{ path: "priv/global_gpg_homedir", keyring: "global_gpg_keyring.kbx" } _Note: the following configuration is used in the "Examples" section throughout this documentation:_ config :gpg_ex, global_keystore: %{ path: "/tmp/gpg_ex_keystore" } """ alias GPGex.Keystore @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. Also returns the command arguments if it failed. See [gnupg/doc/DETAILS](https://github.com/gpg/gnupg/blob/master/doc/DETAILS#format-of-the-status-fd-output) for a full list and description of statuses and their arguments. ## Options * `:keystore` - a `GPGex.Keystore` struct. ## Examples iex> {:ok, messages, stdout} = GPGex.cmd(["--recv-keys", "18D5DCA13E5D61587F552A1BDEB5A837B34DD01D"]) iex> messages [ "KEY_CONSIDERED 18D5DCA13E5D61587F552A1BDEB5A837B34DD01D 0", "IMPORTED DEB5A837B34DD01D GPGEx Test ", "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 \" 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"], ["--homedir", "/tmp/gpg_ex_keystore", "--batch", "--status-fd=1", "--recv-keys", "91C8AFC4674BF0963E7A90CEB7FFBE9D2DF23D67"] } """ @spec cmd([String.t()], keyword()) :: {:ok, [String.t()], [String.t()]} | {:error, [String.t()], [String.t()], [String.t()]} def cmd(args, opts \\ []) when is_list(args) do %{keystore: keystore} = Enum.into(opts, %{keystore: nil}) args = base_args(keystore) ++ args {res, code} = System.cmd( "gpg", args, into: [], stderr_to_stdout: true ) [messages, stdout] = process_output(res) case code do 0 -> {:ok, messages, stdout} _ -> {:error, messages, stdout, args} end end @doc """ Same as `cmd/1` but raises a `RuntimeError` if the command fails. ## Examples iex> GPGex.cmd!(["--unknown-option"]) ** (RuntimeError) GPG command 'gpg --homedir /tmp/gpg_ex_keystore --batch --status-fd=1 --unknown-option' failed with: invalid option "--unknown-option" iex> {_, _} = GPGex.cmd!(["--recv-keys", "18D5DCA13E5D61587F552A1BDEB5A837B34DD01D"]) """ @spec cmd!([String.t()], keyword()) :: {[String.t()], [String.t()]} def cmd!(args, opts \\ []) when is_list(args) do case cmd(args, opts) do {:ok, messages, stdout} -> {messages, stdout} {:error, _, stdout, args} -> raise RuntimeError, "GPG command 'gpg #{Enum.join(args, " ")}' failed with:\n#{Enum.join(stdout, "\n")}" 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(keystore) do keystore = keystore || Keystore.get_keystore_global() keystore_args = Keystore.get_keystore_args(keystore) keystore_args ++ @base_args end end