Skip to content

Commit

Permalink
feat: add cmd/1 & cmd!/1 to run GPG commands
Browse files Browse the repository at this point in the history
  • Loading branch information
sheerlox committed Nov 25, 2023
1 parent b5d2705 commit 5a2f129
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 9 deletions.
13 changes: 13 additions & 0 deletions README.md
Expand Up @@ -2,8 +2,17 @@

A simple wrapper to run GPG commands.

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

> [!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_.
## Installation

This library relies on `gpg` being available in your PATH.

The package can be installed by adding `gpg_ex` to your list of dependencies in `mix.exs`:

```elixir
Expand All @@ -15,3 +24,7 @@ end
```

Full documentation can be found at [https://hexdocs.pm/gpg_ex](https://hexdocs.pm/gpg_ex).

## Versioning

This project follows the principles of [Semantic Versioning (SemVer)](https://semver.org/).
6 changes: 6 additions & 0 deletions config/config.exs
@@ -0,0 +1,6 @@
import Config

if config_env() == :test do
config :gpg_ex,
gpg_home: "/tmp/gpg_ex_test_gpg_home"
end
102 changes: 96 additions & 6 deletions lib/gpg_ex.ex
@@ -1,18 +1,108 @@
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"
"""

@doc """
Hello world.
@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> GPGex.hello()
:world
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.
"""
def hello do
:world
@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
21 changes: 18 additions & 3 deletions test/gpg_ex_test.exs
@@ -1,8 +1,23 @@
defmodule GPGexTest do
use ExUnit.Case
doctest GPGex

test "greets the world" do
assert GPGex.hello() == :world
setup_all do
with {:ok, gpg_home} <- Application.fetch_env(:gpg_ex, :gpg_home) do
if !String.starts_with?(gpg_home, "/tmp"),
do: raise("Trying to delete '#{gpg_home}' which is outside of '/tmp'. Aborting.")

File.rm_rf!(gpg_home)
File.mkdir!(gpg_home)
File.chmod!("#{gpg_home}", 0o700)

System.shell("gpg --homedir #{gpg_home} --fingerprint",
into: [],
stderr_to_stdout: true
)
end

:ok
end

doctest GPGex
end
2 changes: 2 additions & 0 deletions test/test_helper.exs
@@ -1 +1,3 @@
ExUnit.configure(seed: 0)

ExUnit.start()

0 comments on commit 5a2f129

Please sign in to comment.