├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── exenv.ex ├── exenv │ ├── adapter.ex │ ├── adapters │ │ └── dotenv.ex │ ├── config.ex │ ├── encryption.ex │ ├── encryption │ │ ├── master_key.ex │ │ └── secrets.ex │ ├── error.ex │ ├── server.ex │ ├── supervisor.ex │ ├── test.ex │ └── utils.ex └── mix │ ├── exenv.decrypt.ex │ ├── exenv.encrypt.ex │ └── exenv.master_key.ex ├── mix.exs ├── mix.lock └── test ├── exenv ├── adapters │ └── dotenv_test.exs └── encryption_test.exs ├── exenv_test.exs ├── fixtures ├── dotenv.env ├── dotenv.env.enc └── master.key ├── support ├── mockenv.ex └── utils.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | exenv-*.tar 24 | 25 | /.elixir_ls -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.7 5 | 6 | script: 7 | - mix test 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Nicholas Sweeting 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exenv 2 | 3 | [![Build Status](https://travis-ci.org/nsweeting/exenv.svg?branch=master)](https://travis-ci.org/nsweeting/exenv) 4 | [![Exenv Version](https://img.shields.io/hexpm/v/exenv.svg)](https://hex.pm/packages/exenv) 5 | 6 | Exenv provides an adapter-based solution to loading environment variables from 7 | external sources. 8 | 9 | It comes with the following adapter: 10 | 11 | * `Exenv.Adapters.Dotenv` (load from .env files) 12 | 13 | But has support from external adapters as well: 14 | 15 | * [Exenv.Adapters.Yaml](https://github.com/nsweeting/exenv_yaml) (load from .yml files) 16 | 17 | ## Installation 18 | 19 | This package can be installed by adding `exenv` to your list of dependencies in `mix.exs`: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:exenv, "~> 0.3"} 25 | ] 26 | end 27 | ``` 28 | 29 | ## Documentation 30 | 31 | Please see [HexDocs](https://hexdocs.pm/exenv/Exenv.html#content) for additional 32 | documentation. This readme provides a brief overview, but it is recommended that 33 | the docs are used. 34 | 35 | ## Getting Started 36 | 37 | If all you want is to load a `.env` file on application start - then you're already 38 | done! Out of the box, `Exenv` is configured to start itself with the `Exenv.Adapters.Dotenv` 39 | adapter configured with sane defaults. This means autoloading on application start - with 40 | the `.env` file required to be within your projects root directory. 41 | 42 | ## Configuration 43 | 44 | If you need finer grained control of things, `Exenv` provides extensive config mechansims. 45 | 46 | We can pass configuration options to `Exenv` from application config. 47 | 48 | ```elixir 49 | config :exenv, [ 50 | adapters: [ 51 | {Exenv.Adapters.Dotenv, [file: "path/to/.env"]} 52 | ] 53 | ] 54 | ``` 55 | 56 | You can also run `Exenv` via your own supervision tree. In this case, you must instruct 57 | `Exenv` not to start itself. 58 | 59 | ```elixir 60 | config :exenv, start_on_application: false 61 | ``` 62 | 63 | Which allows you to add `Exenv` to your own application. 64 | 65 | ```elixir 66 | defmodule MySupervisor do 67 | use Supervisor 68 | 69 | def start_link(opts) do 70 | Supervisor.start_link(__MODULE__, :ok, opts) 71 | end 72 | 73 | def init(:ok) do 74 | children = [ 75 | {Exenv, [adapters: [{Exenv.Adapters.Dotenv, [file: "path/to/.env"]}]]} 76 | ] 77 | 78 | Supervisor.init(children, strategy: :one_for_one) 79 | end 80 | end 81 | ``` 82 | 83 | Options passed to the `child_spec/1` callback take precedence over any application 84 | config. 85 | 86 | By default, all adapters will autoload their environment vars when `Exenv` starts up. 87 | You can override this behaviour on a per-adapter basis, by simply passing the 88 | `autoload: false` key within your adapter config. 89 | 90 | ```elixir 91 | [ 92 | adapters: [ 93 | {Exenv.Adapters.Dotenv, [autoload: false, file: "path/to/.env"]} 94 | ] 95 | ] 96 | ``` 97 | 98 | You must then manually load all env vars from your defined adapters: 99 | 100 | ```elixir 101 | Exenv.load() 102 | ``` 103 | 104 | ## Runtime path evaluation 105 | 106 | Any location where you pass a file path you can choose to instead pass an mfa 107 | which will be run and should evaluate to a proper file path. This allows for easier 108 | runtime setup of files. 109 | 110 | ```elixir 111 | config :exenv, [ 112 | adapters: [ 113 | {Exenv.Adapters.Dotenv, [file: {MyApp, :get_dotenv, []}]} 114 | ] 115 | ] 116 | ``` 117 | 118 | ## Encryption 119 | 120 | Exenv has secrets encryption out of the box. Support will depend on the whether 121 | the adapter provides it. Using secrets encryption allows you to keep an encrypted 122 | version of your secrets checked into your repository. As long as the master key 123 | is accessible, these secrets can then be decrypted. 124 | 125 | To get started with secrets encryption, first generate a master key. 126 | 127 | ```bash 128 | mix exenv.master_key /config/master.key 129 | ``` 130 | 131 | This will generate a new master key at `/config/master.key`. You can then encrypt 132 | your secrets file. 133 | 134 | ```bash 135 | mix exenv.encrypt /config/master.key /config/.env 136 | ``` 137 | 138 | This will encrypt the contents of `/config/.env` using `/config/master.key`. A new 139 | file will then be generated at `/config/.env.enc` with your encrypted secrets. 140 | 141 | You must then provide the proper options to your adapters to enable encryption. 142 | 143 | ```elixir 144 | {Exenv.Adapters.Dotenv, [file: "path/to/.env.enc", encryption: true]} 145 | ``` 146 | 147 | The above will attempt to decrypt `"path/to/.env.enc"` using the contents of the 148 | `"MASTER_KEY"` env var. Alternatively, you can also provide a direct path to the 149 | master key file. 150 | 151 | ```elixir 152 | {Exenv.Adapters.Dotenv, [file: "path/to/.env.enc", encryption: [master_key: "path/to/master.key"]]} 153 | ``` 154 | 155 | To edit your secrets, you just need to decrypt the original encrypted secrets, and 156 | rencrypt the edited file. 157 | 158 | ```bash 159 | mix exenv.decrypt /config/master.key /config/.env.enc 160 | 161 | ## Add to file 162 | 163 | mix exenv.encrypt /config/master.key /config/.env 164 | ``` 165 | -------------------------------------------------------------------------------- /lib/exenv.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv do 2 | @moduledoc """ 3 | Loads env vars using an adapter-based approach. 4 | 5 | Exenv dynamically assigns env vars on application start using whatever adapters 6 | have been configured to run. By default, Exenv is setup to use the included 7 | `Exenv.Adapters.Dotenv` adapter - loading env vars from a `.env` file in your 8 | projects directory on startup. 9 | 10 | ## Configuration 11 | 12 | If you need to further configure Exenv - it is typically done via application config. 13 | 14 | config :exenv, [ 15 | adapters: [ 16 | {Exenv.Adapters.Dotenv, [file: "path/to/.env"]} 17 | ] 18 | ] 19 | 20 | You can simply list the adapters and any options you would like to pass to it 21 | via `{MyAdapter, opts}` - where `opts` is a keyword list of options defined by 22 | the adapter. 23 | 24 | Alternatively, you can also configure Exenv to be used via your own supervision 25 | tree. In this case simply add the following to your config: 26 | 27 | config :exenv, start_on_application: false 28 | 29 | You can then add Exenv to your supervisor. 30 | 31 | children = [ 32 | {Exenv, [adapters: [{Exenv.Adapters.Dotenv, [file: "path/to/.env"]}]]} 33 | ] 34 | 35 | ## Encryption 36 | 37 | Exenv has support for encryption out of the box. This allows you to keep an 38 | encrypted secrets file checked into your repository. Please see `Exenv.Encryption` 39 | for more details. 40 | 41 | """ 42 | 43 | use Application 44 | 45 | alias Exenv.Utils 46 | 47 | @type on_load :: [{Exenv.Adapter.t(), Exenv.Adapter.result()}] 48 | 49 | @impl true 50 | @spec start(any(), any()) :: {:ok, pid()} 51 | def start(_type, _args) do 52 | if Exenv.Config.get(:start_on_application) do 53 | start_link() 54 | else 55 | Supervisor.start_link([], strategy: :one_for_one) 56 | end 57 | end 58 | 59 | @doc """ 60 | Starts the Exenv process. 61 | """ 62 | @spec start_link(any()) :: Supervisor.on_start() 63 | def start_link(opts \\ []) do 64 | Exenv.Supervisor.start_link(opts) 65 | end 66 | 67 | @doc false 68 | def child_spec(opts \\ []) do 69 | %{ 70 | id: Exenv.Supervisor, 71 | start: {Exenv.Supervisor, :start_link, [opts]} 72 | } 73 | end 74 | 75 | @doc """ 76 | Returns `{:ok, binary}`, where binary is a binary data object that contains the 77 | contents of path, or `{:error, reason}` if an error occurs. 78 | 79 | You can optionally pass an mfa `{module, function, args}` that will be evaluated 80 | and should return the intended path. This allows for runtime setup. 81 | 82 | ## Options 83 | * `:encryption` - options used to decrypt the binary result if required. 84 | 85 | ``` 86 | # Decrypts the file using the MASTER_KEY env var 87 | [encryption: true] 88 | 89 | # Decrypts the file using the master key file 90 | [encryption: [master_key: "/path/to/master.key"]] 91 | ``` 92 | 93 | """ 94 | @spec read_file(binary() | mfa(), keyword()) :: {:ok, binary} | {:error, any()} 95 | def read_file(path_or_mfa, opts \\ []) do 96 | try do 97 | path = Utils.build_path(path_or_mfa) 98 | encryption = Keyword.get(opts, :encryption, false) 99 | 100 | file = 101 | if encryption do 102 | encryption = if is_list(encryption), do: encryption, else: [] 103 | 104 | encryption 105 | |> Keyword.get(:master_key) 106 | |> Exenv.Encryption.get_master_key!() 107 | |> Exenv.Encryption.decrypt_secrets!(path) 108 | else 109 | File.read!(path) 110 | end 111 | 112 | {:ok, file} 113 | rescue 114 | error -> {:error, error} 115 | end 116 | end 117 | 118 | @doc """ 119 | Loads all env vars using the adapters defined within our config. 120 | """ 121 | @spec load() :: on_load() 122 | def load do 123 | Exenv.Server.load() 124 | end 125 | 126 | @doc """ 127 | Loads all env vars using the adapter config provided. 128 | """ 129 | @spec load(adapters :: [Exenv.Adapter.config()]) :: on_load() 130 | def load(adapters) when is_list(adapters) do 131 | for {adapter, opts} <- adapters do 132 | result = load(adapter, opts) 133 | {adapter, result} 134 | end 135 | end 136 | 137 | @doc """ 138 | Loads env vars using the adapter and options provided. 139 | """ 140 | @spec load(adapter :: Exenv.Adapter.t(), opts :: keyword()) :: Exenv.Adapter.result() 141 | def load(adapter, opts) when is_atom(adapter) and is_list(opts) do 142 | apply(adapter, :load, [opts]) 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/exenv/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Adapter do 2 | @moduledoc """ 3 | Defines an Exenv adapter. 4 | 5 | An Exenv adapter is simply a module that adheres to the callbacks required. It 6 | can be as simple as: 7 | 8 | defmodule MyAdapter do 9 | use Exenv.Adapter 10 | 11 | @imple true 12 | def load(opts) do 13 | # load some system env vars 14 | 15 | :ok 16 | end 17 | end 18 | 19 | Some adapters may be simple and do not require a process on their own. But if 20 | some form of state is needed, we can also make our adapter process-based. 21 | If we define our adapter within the normal Exenv startup flow, this process 22 | will then be automatically started and supervised. Below is an example: 23 | 24 | defmodule MyAdapter do 25 | use Exenv.Adapter 26 | use GenServer 27 | 28 | @impl true 29 | def start_link(opts) do 30 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 31 | end 32 | 33 | @impl true 34 | def init(opts) do 35 | {:ok, opts} 36 | end 37 | 38 | @impl true 39 | def load(opts) do 40 | # load some system env vars 41 | 42 | GenServer.call(__MODULE__, {:load, opts}) 43 | end 44 | 45 | @impl true 46 | def handle_call({:load, opts}, _from, config) do 47 | # load some system env vars 48 | 49 | {:reply, :ok, config} 50 | end 51 | end 52 | 53 | And thats it! We can know start using our new adapter. 54 | 55 | ## Reading Files 56 | 57 | If your adapter reads files in order to load env vars, it is recommended that 58 | `Exenv.read_file/2` is used. This will enabled support for secrets encryption. 59 | If a user passes the one of the following with your options, the file will 60 | be automatically decrypted. 61 | 62 | # Decrypts the file using MASTER_KEY env var 63 | [encryption: true] 64 | 65 | # Decrypts the file using a master key file 66 | [encryption: [master_key: "/path/to/master.key"]] 67 | 68 | """ 69 | 70 | @doc """ 71 | Starts the adapter process if required. 72 | """ 73 | @callback start_link(opts :: keyword()) :: GenServer.on_start() 74 | 75 | @doc """ 76 | Loads the system env vars using the adapter and options provided. 77 | """ 78 | @callback load(opts :: keyword()) :: result() 79 | 80 | @type t :: module() 81 | 82 | @type config :: {Exenv.Adapter.t(), keyword()} 83 | 84 | @type result :: :ok | {:error, term()} 85 | 86 | defmacro __using__(_) do 87 | quote do 88 | @behaviour Exenv.Adapter 89 | 90 | @doc false 91 | def start_link(_opts) do 92 | :ignore 93 | end 94 | 95 | @doc false 96 | def child_spec(config) do 97 | %{ 98 | id: __MODULE__, 99 | start: {__MODULE__, :start_link, [config]} 100 | } 101 | end 102 | 103 | @doc false 104 | def load(_opts) do 105 | {:error, :not_implemented} 106 | end 107 | 108 | defoverridable Exenv.Adapter 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/exenv/adapters/dotenv.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Adapters.Dotenv do 2 | @moduledoc """ 3 | Loads env vars from `.env` files. 4 | 5 | Below is a simple example of a `.env` file: 6 | 7 | KEY1=val 8 | KEY2=val 9 | KEY3=val 10 | 11 | Assuming we have the above file in our project root directory, we would be 12 | able to access any of the above env vars. 13 | 14 | System.get_env("KEY1") 15 | 16 | By default, this adapter is set to start automatically on `Exenv` startup. It 17 | also has support for encrypted `.env` files. 18 | 19 | """ 20 | 21 | use Exenv.Adapter 22 | 23 | @doc """ 24 | Loads the system env vars from a `.env` specified in the options. 25 | 26 | ## Options 27 | * `:file` - The file path or mfa that evaluates to a file path in which to 28 | read the `.env` from. By default this is a `.env` file in your projects root directory. 29 | * `:encryption` - Options used to decrypt files. Please see `Exenv.read_file/2` 30 | for the options available. 31 | 32 | """ 33 | @impl true 34 | def load(opts) do 35 | with {:ok, env_file} <- get_env_file(opts) do 36 | env_vars = parse_raw(env_file) 37 | System.put_env(env_vars) 38 | end 39 | end 40 | 41 | defp get_env_file(opts) do 42 | file = Keyword.get_lazy(opts, :file, fn -> File.cwd!() <> "/.env" end) 43 | Exenv.read_file(file, opts) 44 | end 45 | 46 | defp parse_raw(binary) do 47 | binary 48 | |> String.split("\n") 49 | |> Stream.map(&parse_line(&1)) 50 | |> Stream.map(&parse_var(&1)) 51 | |> Stream.filter(&(valid_var?(&1) == true)) 52 | |> Enum.to_list() 53 | end 54 | 55 | defp parse_line(line) do 56 | line 57 | |> String.trim() 58 | |> String.split("=", parts: 2) 59 | |> List.to_tuple() 60 | end 61 | 62 | defp parse_var({key, val}) do 63 | {key |> String.trim() |> String.upcase(), String.trim(val)} 64 | end 65 | 66 | defp parse_var(_var) do 67 | :error 68 | end 69 | 70 | defp valid_var?({key, val}) when is_binary(key) and is_binary(val) do 71 | true 72 | end 73 | 74 | defp valid_var?(_) do 75 | false 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/exenv/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Config do 2 | @moduledoc false 3 | 4 | @default_config [ 5 | start_on_application: true, 6 | adapters: [ 7 | {Exenv.Adapters.Dotenv, []} 8 | ] 9 | ] 10 | 11 | @spec get(atom(), any()) :: any() 12 | def get(key, default \\ nil) do 13 | all() |> Keyword.get(key, default) 14 | end 15 | 16 | @spec set(atom(), any()) :: :ok 17 | def set(key, val) do 18 | Application.put_env(:exenv, key, val) 19 | end 20 | 21 | @spec all() :: keyword() 22 | def all do 23 | config = :exenv |> Application.get_all_env() |> Keyword.delete(:included_applications) 24 | Keyword.merge(@default_config, config) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/exenv/encryption.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Encryption do 2 | @moduledoc """ 3 | Provides support for secrets encryption. 4 | 5 | Exenv supports file encryption out of the box. As a result, most adapters will 6 | also support encryption. This allows you to keep an encrypted secrets file 7 | checked into your repository. As long as you provide access to a master 8 | key via the env var `"MASTER_KEY"` or file, you will be able to transparently 9 | load env vars from your encrypted secrets file. 10 | 11 | To start using encryption, you must first generate a master key: 12 | 13 | mix exenv.master_key /config/master.key 14 | 15 | The above will generate a master key at `/config/master.key` 16 | 17 | You can then encrypt your secrets file: 18 | 19 | mix exenv.encrypt /config/master.key /config/.env 20 | 21 | The above will encrypt the `/config/.env` file using the key at `/config/master.key` 22 | 23 | You can also decrypt your secrets at any time if you wish to add to them: 24 | 25 | mix exenv.decrypt /config/master.key /config/.env.enc 26 | 27 | The above will decrypt the `/config/.env.enc` file using the key at `/config/master.key` 28 | and create a new file at `/config/.env` contining the decrypted secrets. 29 | 30 | Encryption options are passed along with adapter options. Please consult the 31 | options available to individual adapters for further details. 32 | 33 | {Exenv.Adapters.Dotenv, [file: "path/to/.env", encryption: true]} 34 | 35 | """ 36 | 37 | @doc """ 38 | Encrypts the secrets located at `path` using `key`. 39 | 40 | Returns the path to the new encrypted file. 41 | """ 42 | @spec encrypt_secrets!(binary(), binary() | mfa()) :: binary() | no_return() 43 | defdelegate encrypt_secrets!(key, path_or_mfa), to: Exenv.Encryption.Secrets, as: :encrypt! 44 | 45 | @doc """ 46 | Decrypts the secrets at `path` using `key`. 47 | 48 | Returns the decrypted secrets. 49 | """ 50 | @spec decrypt_secrets!(binary(), binary() | mfa()) :: binary() | no_return() 51 | defdelegate decrypt_secrets!(key, path_or_mfa), to: Exenv.Encryption.Secrets, as: :decrypt! 52 | 53 | @doc """ 54 | Attempts to get the master key. 55 | 56 | If provided `path` it will read the key from path. If not provided a path, 57 | it will get the contents of the env var `"MASTER_KEY"`. 58 | """ 59 | @spec get_master_key!(any()) :: binary() | no_return() 60 | defdelegate get_master_key!(path_or_mfa \\ nil), to: Exenv.Encryption.MasterKey, as: :get! 61 | 62 | @doc """ 63 | Creates a master key at `path`. 64 | 65 | Returns the path to the new master key file. 66 | """ 67 | @spec create_master_key!(binary() | mfa()) :: binary() | no_return() 68 | defdelegate create_master_key!(path_or_mfa), to: Exenv.Encryption.MasterKey, as: :create! 69 | end 70 | -------------------------------------------------------------------------------- /lib/exenv/encryption/master_key.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Encryption.MasterKey do 2 | @moduledoc false 3 | 4 | alias Exenv.Utils 5 | 6 | @spec create!(binary() | mfa()) :: binary() 7 | def create!(path_or_mfa) do 8 | path = Utils.build_path(path_or_mfa) 9 | ensure_empty(path) 10 | generate_key(path) 11 | 12 | path 13 | end 14 | 15 | @spec get!(any()) :: binary() | no_return() 16 | def get!(path_or_mfa \\ nil) 17 | 18 | def get!(nil) do 19 | case System.get_env("MASTER_KEY") do 20 | <> -> key 21 | _ -> raise Exenv.Error, "MASTER_KEY env variable missing" 22 | end 23 | end 24 | 25 | def get!(path) do 26 | path 27 | |> Utils.build_path() 28 | |> File.read!() 29 | end 30 | 31 | defp ensure_empty(path) do 32 | if File.exists?(path) do 33 | raise Exenv.Error, """ 34 | Master key already exists. 35 | 36 | Please remove this file if you wish to generate a new master key. 37 | 38 | - #{path} 39 | """ 40 | end 41 | end 42 | 43 | defp generate_key(path) do 44 | key = 32 |> :crypto.strong_rand_bytes() |> Utils.encode() 45 | File.write!(path, key) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/exenv/encryption/secrets.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Encryption.Secrets do 2 | @moduledoc false 3 | 4 | alias Exenv.Utils 5 | 6 | @aes_block_size 16 7 | @secrets_file_type ".enc" 8 | 9 | @spec encrypted_path(binary()) :: binary() 10 | def encrypted_path(path) do 11 | path <> @secrets_file_type 12 | end 13 | 14 | @spec encrypt!(binary(), binary() | mfa()) :: binary() | no_return() 15 | def encrypt!(key, path_or_mfa) do 16 | key = Utils.decode(key) 17 | path = Utils.build_path(path_or_mfa) 18 | secrets = File.read!(path) 19 | encrypted_path = encrypted_path(path) 20 | init_vector = :crypto.strong_rand_bytes(16) 21 | secrets = pad(secrets, @aes_block_size) 22 | 23 | case :crypto.block_encrypt(:aes_cbc256, key, init_vector, secrets) do 24 | <> -> 25 | init_vector = Utils.encode(init_vector) 26 | cipher_text = Utils.encode(cipher_text) 27 | 28 | File.write!(encrypted_path, "#{init_vector}|#{cipher_text}") 29 | encrypted_path 30 | 31 | _x -> 32 | raise Exenv.Error, "encryption failed" 33 | end 34 | end 35 | 36 | @spec decrypt!(binary(), binary() | mfa()) :: binary() | no_return() 37 | def decrypt!(key, path_or_mfa) do 38 | path_or_mfa 39 | |> Utils.build_path() 40 | |> File.read!() 41 | |> String.split("|") 42 | |> Enum.map(&String.trim/1) 43 | |> Enum.map(&Utils.decode/1) 44 | |> case do 45 | [init_vector, cipher_text] -> 46 | key = Utils.decode(key) 47 | 48 | plain_text = :crypto.block_decrypt(:aes_cbc256, key, init_vector, cipher_text) 49 | unpad(plain_text) 50 | 51 | _ -> 52 | raise Exenv.Error, "decryption failed" 53 | end 54 | rescue 55 | _ -> raise Exenv.Error, "decryption failed" 56 | end 57 | 58 | defp pad(data, block_size) do 59 | to_add = block_size - rem(byte_size(data), block_size) 60 | data <> to_string(:string.chars(to_add, to_add)) 61 | end 62 | 63 | defp unpad(data) do 64 | to_remove = :binary.last(data) 65 | :binary.part(data, 0, byte_size(data) - to_remove) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/exenv/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Error do 2 | defexception [:message] 3 | end 4 | 5 | -------------------------------------------------------------------------------- /lib/exenv/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Server do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @spec start_link(any()) :: GenServer.on_start() 7 | def start_link(config \\ []) do 8 | GenServer.start_link(__MODULE__, config, name: __MODULE__) 9 | end 10 | 11 | def child_spec(config) do 12 | %{ 13 | id: __MODULE__, 14 | start: {__MODULE__, :start_link, [config]} 15 | } 16 | end 17 | 18 | @spec set_adapters(any()) :: :ok 19 | def set_adapters(adapters) do 20 | GenServer.call(__MODULE__, {:set_adapters, adapters}) 21 | end 22 | 23 | @spec load() :: Exenv.on_load() 24 | def load do 25 | GenServer.call(__MODULE__, :load) 26 | end 27 | 28 | @doc false 29 | @impl true 30 | def init(config) do 31 | autoload_adapters(config) 32 | {:ok, config} 33 | end 34 | 35 | @doc false 36 | @impl true 37 | def handle_call({:set_adapters, adapters}, _from, config) do 38 | config = Keyword.merge(config, adapters: adapters) 39 | {:reply, :ok, config} 40 | end 41 | 42 | def handle_call(:load, _from, config) do 43 | results = do_load(config) 44 | {:reply, results, config} 45 | end 46 | 47 | defp autoload_adapters(config) do 48 | config 49 | |> Keyword.get(:adapters, []) 50 | |> Enum.filter(fn {_, opts} -> Keyword.get(opts, :autoload, true) end) 51 | |> Exenv.load() 52 | end 53 | 54 | defp do_load(config) do 55 | adapters = Keyword.get(config, :adapters, []) 56 | Exenv.load(adapters) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/exenv/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | @spec start_link(keyword()) :: Supervisor.on_start() 7 | def start_link(opts) do 8 | Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 9 | end 10 | 11 | @spec init(keyword()) :: {:ok, {:supervisor.sup_flags(), [:supervisor.child_spec()]}} 12 | def init(opts) do 13 | config = Exenv.Config.all() |> Keyword.merge(opts) 14 | children = adapter_children(config) ++ [{Exenv.Server, config}] 15 | sup_opts = [strategy: :one_for_one] 16 | 17 | Supervisor.init(children, sup_opts) 18 | end 19 | 20 | defp adapter_children(config) do 21 | Keyword.get(config, :adapters, []) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/exenv/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Test do 2 | import ExUnit.Assertions 3 | import ExUnit.Callbacks, only: [start_supervised: 1] 4 | 5 | @dialyzer {:nowarn_function, refute_var: 2} 6 | 7 | @doc """ 8 | Refutes that a list of env vars exist. 9 | 10 | ## Examples 11 | refute_vars [{"KEY", "val"}] 12 | 13 | """ 14 | @spec refute_vars(any()) :: [any()] 15 | def refute_vars(test_vars) do 16 | for {key, val} <- test_vars do 17 | refute_var(key, val) 18 | end 19 | end 20 | 21 | @doc """ 22 | Refutes that a single env var exists. 23 | 24 | ## Examples 25 | refute_var "KEY", "val" 26 | 27 | """ 28 | def refute_var(key, val) when is_binary(key) do 29 | refute System.get_env(key) == val 30 | end 31 | 32 | @doc """ 33 | Asserts that a list of env vars exist. 34 | 35 | ## Examples 36 | assert_vars [{"KEY", "val"}] 37 | 38 | """ 39 | def assert_vars(test_vars) do 40 | for {key, val} <- test_vars do 41 | assert_var(key, val) 42 | end 43 | end 44 | 45 | @doc """ 46 | Asserts that a single env var exists. 47 | 48 | ## Examples 49 | refute_var "KEY", "val" 50 | 51 | """ 52 | def assert_var(key, val) do 53 | assert System.get_env(key) == val 54 | end 55 | 56 | @doc """ 57 | Resets a list of env vars to empty strings. 58 | 59 | ## Examples 60 | reset_env_vars [{"KEY", "val"}] 61 | 62 | """ 63 | def reset_env_vars(env_vars) do 64 | for {key, _} <- env_vars do 65 | System.put_env(key, "") 66 | end 67 | end 68 | 69 | @doc """ 70 | Sets up Exenv for a test with the provided options. 71 | 72 | ## Examples 73 | setup_exenv(adapters: [{MyAdapter, opts}]) 74 | 75 | """ 76 | def setup_exenv(opts \\ []) do 77 | start_supervised({Exenv.Supervisor, opts}) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/exenv/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Utils do 2 | @moduledoc false 3 | 4 | @spec build_path(binary() | mfa()) :: binary() 5 | def build_path(path) when is_binary(path) do 6 | path 7 | end 8 | 9 | def build_path({mod, fun, args}) do 10 | apply(mod, fun, args) 11 | end 12 | 13 | @spec encode(binary()) :: binary() 14 | def encode(data) do 15 | Base.url_encode64(data, padding: false) 16 | end 17 | 18 | @spec decode(binary()) :: binary() 19 | def decode(data) do 20 | Base.url_decode64!(data, padding: false) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mix/exenv.decrypt.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Exenv.Decrypt do 2 | @shortdoc "Decrypts a file using the provided master.key" 3 | @moduledoc """ 4 | Decrypts a file using the provided master.key. 5 | 6 | The encrypted file should end in `.enc`. 7 | 8 | mix exenv.decrypt /config/master.key /config/secrets.env.enc 9 | 10 | """ 11 | 12 | use Mix.Task 13 | 14 | alias Exenv.Encryption 15 | 16 | @impl Mix.Task 17 | def run([key_path, secrets_path]) do 18 | cwd = File.cwd!() 19 | full_key_path = cwd <> key_path 20 | encrypted_secrets_path = cwd <> secrets_path 21 | key = Encryption.get_master_key!(full_key_path) 22 | decrypted_secrets = Encryption.decrypt_secrets!(key, encrypted_secrets_path) 23 | decrypted_secrets_path = String.replace(encrypted_secrets_path, ".enc", "") 24 | File.write!(decrypted_secrets_path, decrypted_secrets) 25 | 26 | print_info(decrypted_secrets_path) 27 | end 28 | 29 | defp print_info(decrypted_secrets_path) do 30 | Mix.Shell.IO.info(""" 31 | Secrets decrypted at #{decrypted_secrets_path}. 32 | """ 33 | ) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/mix/exenv.encrypt.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Exenv.Encrypt do 2 | @shortdoc "Encrypts a file using the provided master.key" 3 | @moduledoc """ 4 | Encrypts a file using the provided master.key. 5 | 6 | The encrypted file will use the existing file name with the addition of `.enc`. 7 | The original unencrypted secrets file will be added to your `.gitignore`. 8 | 9 | mix exenv.decrypt /config/master.key /config/secrets.env 10 | 11 | """ 12 | 13 | use Mix.Task 14 | 15 | alias Exenv.Encryption 16 | 17 | @impl Mix.Task 18 | def run([key_path, secrets_path]) do 19 | cwd = File.cwd!() 20 | full_key_path = cwd <> key_path 21 | full_secrets_path = cwd <> secrets_path 22 | key = Encryption.get_master_key!(full_key_path) 23 | encrypted_path = Encryption.encrypt_secrets!(key, full_secrets_path) 24 | 25 | add_gitignore(secrets_path) 26 | print_info(encrypted_path) 27 | end 28 | 29 | defp add_gitignore(path) do 30 | device = File.open!(".gitignore", [:read, :append]) 31 | gitignore = device |> IO.binread(:all) |> String.split("\n") 32 | 33 | unless Enum.member?(gitignore, path) do 34 | IO.binwrite(device, "\n") 35 | IO.binwrite(device, "\n") 36 | IO.binwrite(device, "# Ignore the unencrypted secrets file.\n") 37 | IO.binwrite(device, path) 38 | end 39 | 40 | File.close(device) 41 | end 42 | 43 | defp print_info(encrypted_path) do 44 | Mix.Shell.IO.info(""" 45 | Secrets encrypted at #{encrypted_path}. 46 | Unencrypted secrets have been added to your projects .gitignore. 47 | """ 48 | ) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/mix/exenv.master_key.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Exenv.MasterKey do 2 | @shortdoc "Generates a master.key file within the given path" 3 | @moduledoc """ 4 | Generates a master key file at the given path. 5 | 6 | The generated file will be added to your `.gitignore` file. 7 | 8 | mix exenv.master_key /config/master.key 9 | 10 | """ 11 | 12 | use Mix.Task 13 | 14 | alias Exenv.Encryption 15 | 16 | @impl Mix.Task 17 | def run([]) do 18 | run(["/config/master.key"]) 19 | end 20 | 21 | def run([path]) do 22 | path = sanitize_path(path) 23 | full_path = File.cwd!() <> path 24 | key = Encryption.create_master_key!(full_path) 25 | 26 | add_gitignore(path) 27 | print_info(key) 28 | end 29 | 30 | defp sanitize_path(path) do 31 | if String.first(path) == "/", do: path, else: "/#{path}" 32 | end 33 | 34 | defp add_gitignore(path) do 35 | device = File.open!(".gitignore", [:read, :append]) 36 | gitignore = device |> IO.binread(:all) |> String.split("\n") 37 | 38 | unless Enum.member?(gitignore, path) do 39 | IO.binwrite(device, "\n") 40 | IO.binwrite(device, "\n") 41 | IO.binwrite(device, "# Ignore the master key generated for encrypted secrets.\n") 42 | IO.binwrite(device, path) 43 | end 44 | 45 | File.close(device) 46 | end 47 | 48 | defp print_info(path) do 49 | Mix.Shell.IO.info(""" 50 | Master key generated at #{path}. 51 | File has been added to your projects .gitignore. 52 | """) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exenv.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.4.1" 5 | 6 | def project do 7 | [ 8 | app: :exenv, 9 | version: @version, 10 | elixir: "~> 1.7", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | name: "Exenv", 14 | description: description(), 15 | package: package(), 16 | docs: docs() 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | extra_applications: [:logger, :crypto], 24 | mod: {Exenv, []} 25 | ] 26 | end 27 | 28 | defp description do 29 | """ 30 | Exenv makes loading environment variables from external sources easy. 31 | """ 32 | end 33 | 34 | defp package do 35 | [ 36 | files: ["lib", "mix.exs", "README*"], 37 | maintainers: ["Nicholas Sweeting"], 38 | licenses: ["MIT"], 39 | links: %{"GitHub" => "https://github.com/nsweeting/exenv"} 40 | ] 41 | end 42 | 43 | defp docs do 44 | [ 45 | extras: ["README.md"], 46 | main: "readme", 47 | source_url: "https://github.com/nsweeting/exenv" 48 | ] 49 | end 50 | 51 | # Run "mix help deps" to learn about dependencies. 52 | defp deps do 53 | [ 54 | {:ex_doc, "~> 0.23", only: :dev, runtime: false}, 55 | {:dialyxir, "~> 0.5.0", only: [:dev], runtime: false} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"}, 3 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 5 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 6 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 9 | } 10 | -------------------------------------------------------------------------------- /test/exenv/adapters/dotenv_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Adapters.DotenvTest do 2 | use ExUnit.Case 3 | 4 | import Exenv.Test 5 | 6 | alias Exenv.Adapters.Dotenv 7 | 8 | @test_dotenv File.cwd!() <> "/test/fixtures/dotenv.env" 9 | @test_enc_dotenv File.cwd!() <> "/test/fixtures/dotenv.env.enc" 10 | @test_master_key File.cwd!() <> "/test/fixtures/master.key" 11 | @test_vars [ 12 | {"GOOD_KEY1", "foo"}, 13 | {"GOOD_KEY2", "bar"}, 14 | {"GOOD_KEY3", "baz="} 15 | ] 16 | 17 | setup do 18 | setup_exenv(adapters: [{Exenv.Adapters.Dotenv, []}]) 19 | reset_env_vars(@test_vars) 20 | 21 | :ok 22 | end 23 | 24 | describe "load/1" do 25 | test "will set env vars from a specified dotenv file" do 26 | refute_vars(@test_vars) 27 | 28 | Dotenv.load(file: @test_dotenv) 29 | 30 | assert_vars(@test_vars) 31 | end 32 | 33 | test "will set env vars from an mfa" do 34 | refute_vars(@test_vars) 35 | 36 | Dotenv.load(file: {__MODULE__, :test_dotenv, []}) 37 | 38 | assert_vars(@test_vars) 39 | end 40 | 41 | test "will ignore bad lines in a dotenv file" do 42 | Dotenv.load(file: @test_dotenv) 43 | 44 | assert_var("BAD_VAR", nil) 45 | assert_var("baz", nil) 46 | end 47 | 48 | test "will return an error tuple when the file doesnt exist" do 49 | assert {:error, %File.Error{}} = Dotenv.load(file: "bad_file.env") 50 | end 51 | 52 | test "will return an error tuple when the file is a directory" do 53 | assert {:error, %File.Error{}} = Dotenv.load(file: "test") 54 | end 55 | 56 | test "will set env vars from a specified encrypted dotenv file using a master key file" do 57 | refute_vars(@test_vars) 58 | 59 | Dotenv.load(file: @test_enc_dotenv, encryption: [master_key: @test_master_key]) 60 | 61 | assert_vars(@test_vars) 62 | end 63 | 64 | test "will set env vars from an mfa file using a master key mfa" do 65 | refute_vars(@test_vars) 66 | 67 | Dotenv.load( 68 | file: {__MODULE__, :test_enc_dotenv, []}, 69 | encryption: [master_key: {__MODULE__, :test_master_key, []}] 70 | ) 71 | 72 | assert_vars(@test_vars) 73 | end 74 | 75 | test "will set env vars from a specified encrypted dotenv file using a MASTER_KEY env var" do 76 | refute_vars(@test_vars) 77 | master_key = File.read!(@test_master_key) 78 | System.put_env("MASTER_KEY", master_key) 79 | 80 | Dotenv.load(file: @test_enc_dotenv, encryption: true) 81 | 82 | assert_vars(@test_vars) 83 | end 84 | end 85 | 86 | def test_dotenv do 87 | @test_dotenv 88 | end 89 | 90 | def test_enc_dotenv do 91 | @test_enc_dotenv 92 | end 93 | 94 | def test_master_key do 95 | @test_master_key 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/exenv/encryption_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exenv.EncryptionTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Exenv.Support.Utils 5 | 6 | alias Exenv.Encryption 7 | 8 | setup do 9 | setup_temp!() 10 | on_exit(&teardown_temp!/0) 11 | end 12 | 13 | describe "create_master_key!/1" do 14 | test "will create a master.key file in the specified path" do 15 | key_path = (temp_path() <> "master.key") |> Encryption.create_master_key!() 16 | 17 | assert is_binary(key_path) 18 | assert File.exists?(key_path) 19 | end 20 | 21 | test "will create a master.key file using an mfa" do 22 | key_path = {__MODULE__, :master_key, []} |> Encryption.create_master_key!() 23 | 24 | assert is_binary(key_path) 25 | assert File.exists?(key_path) 26 | end 27 | 28 | test "will create a valid key that can be used to encrypt data" do 29 | key_path = (temp_path() <> "master.key") |> Encryption.create_master_key!() 30 | key = File.read!(key_path) 31 | temp_file = temp_path() <> "secrets" 32 | File.write!(temp_file, "foobar") 33 | encrypted_text = Encryption.encrypt_secrets!(key, temp_file) 34 | decrypted_text = Encryption.decrypt_secrets!(key, encrypted_text) 35 | 36 | assert String.length(key) == 43 37 | refute encrypted_text == "foobar" 38 | assert decrypted_text == "foobar" 39 | end 40 | end 41 | 42 | describe "get_master_key!/1" do 43 | test "will fetch the MASTER_KEY env variable" do 44 | random_key = random_key() 45 | System.put_env("MASTER_KEY", random_key) 46 | 47 | assert Encryption.get_master_key!() == random_key 48 | end 49 | 50 | test "will raise an error if the master key env var is incorrect" do 51 | System.put_env("MASTER_KEY", "") 52 | 53 | assert_raise Exenv.Error, fn -> 54 | Encryption.get_master_key!() 55 | end 56 | end 57 | 58 | test "will fetch the master key from the provided path" do 59 | key_path = (temp_path() <> "master.key") |> Encryption.create_master_key!() 60 | key = File.read!(key_path) 61 | 62 | assert Encryption.get_master_key!(key_path) == key 63 | end 64 | 65 | test "will fetch the master key from an mfa" do 66 | key_path = {__MODULE__, :master_key, []} |> Encryption.create_master_key!() 67 | key = File.read!(key_path) 68 | 69 | assert Encryption.get_master_key!(key_path) == key 70 | end 71 | 72 | test "will fetch the MASTER_KEY env variable if path is not valid" do 73 | random_key = random_key() 74 | System.put_env("MASTER_KEY", random_key) 75 | 76 | assert Encryption.get_master_key!(nil) == random_key 77 | end 78 | 79 | test "will raise an error if the file doesnt exist" do 80 | key_path = temp_path() <> random_key() 81 | 82 | assert_raise File.Error, fn -> 83 | Encryption.get_master_key!(key_path) 84 | end 85 | end 86 | end 87 | 88 | def master_key do 89 | temp_path() <> "master.key" 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/exenv_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExenvTest do 2 | use ExUnit.Case 3 | 4 | import Exenv.Test 5 | 6 | @test_vars [ 7 | {"FOO", "bar"}, 8 | {"BAR", "baz"} 9 | ] 10 | 11 | setup do 12 | reset_env_vars(@test_vars) 13 | 14 | :ok 15 | end 16 | 17 | describe "load/0" do 18 | test "will load env vars from the adapters" do 19 | setup_exenv( 20 | adapters: [ 21 | {Exenv.Support.Mockenv.One, [autoload: false, env_vars: [{"FOO", "bar"}]]}, 22 | {Exenv.Support.Mockenv.Two, [autoload: false, env_vars: [{"BAR", "baz"}]]} 23 | ] 24 | ) 25 | 26 | refute_vars(@test_vars) 27 | 28 | Exenv.load() 29 | 30 | assert_vars(@test_vars) 31 | end 32 | 33 | test "will autoload env vars if the option is specified" do 34 | refute_vars(@test_vars) 35 | 36 | setup_exenv( 37 | adapters: [ 38 | {Exenv.Support.Mockenv.One, [autoload: true, env_vars: [{"FOO", "bar"}]]}, 39 | {Exenv.Support.Mockenv.Two, [autoload: true, env_vars: [{"BAR", "baz"}]]} 40 | ] 41 | ) 42 | 43 | assert_vars(@test_vars) 44 | end 45 | 46 | test "will return the results of each adapter" do 47 | setup_exenv( 48 | adapters: [ 49 | {Exenv.Support.Mockenv.One, []}, 50 | {Exenv.Support.Mockenv.Two, []} 51 | ] 52 | ) 53 | 54 | result = Exenv.load() 55 | assert result == [{Exenv.Support.Mockenv.One, :ok}, {Exenv.Support.Mockenv.Two, :ok}] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/fixtures/dotenv.env: -------------------------------------------------------------------------------- 1 | GOOD_KEY1=foo 2 | GOOD_KEY2 = bar 3 | GOOD_KEY3=baz= 4 | BAD_VAL= 5 | baz -------------------------------------------------------------------------------- /test/fixtures/dotenv.env.enc: -------------------------------------------------------------------------------- 1 | YalvNpNBVJ90x381ZdtOPQ|qqa7vh_ARODgAOdjeDzP3pkVAtlghDT9lR8Oouo-GJNXd9_ZJ7kamPVHmRuhAZb16NnAfVikY-0sqTcuEc8AMQ -------------------------------------------------------------------------------- /test/fixtures/master.key: -------------------------------------------------------------------------------- 1 | Iby4S5kIZ-jBbj-WkZootFsrB-5g7Inl1vG1I1jMEkY -------------------------------------------------------------------------------- /test/support/mockenv.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Support.Mockenv do 2 | defmacro __using__(_) do 3 | quote do 4 | use GenServer 5 | use Exenv.Adapter 6 | 7 | alias Exenv.Config 8 | 9 | @impl true 10 | def start_link(opts) do 11 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 12 | end 13 | 14 | @impl true 15 | def init(opts) do 16 | {:ok, opts} 17 | end 18 | 19 | @impl true 20 | def load(_) do 21 | GenServer.call(__MODULE__, :load) 22 | end 23 | 24 | @impl true 25 | def handle_call(:load, _from, config) do 26 | env_vars = Keyword.get(config, :env_vars, []) 27 | System.put_env(env_vars) 28 | {:reply, :ok, config} 29 | end 30 | end 31 | end 32 | end 33 | 34 | defmodule Exenv.Support.Mockenv.One do 35 | use Exenv.Support.Mockenv 36 | end 37 | 38 | defmodule Exenv.Support.Mockenv.Two do 39 | use Exenv.Support.Mockenv 40 | end 41 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Exenv.Support.Utils do 2 | def setup_temp! do 3 | temp_path() |> File.mkdir_p!() 4 | end 5 | 6 | def teardown_temp! do 7 | temp_path() |> File.rm_rf!() 8 | end 9 | 10 | def temp_path do 11 | File.cwd!() <> "/test/temp/" 12 | end 13 | 14 | def random_key do 15 | :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.stop(:exenv) 2 | 3 | {:ok, files} = File.ls("./test/support") 4 | 5 | Enum.each(files, fn file -> 6 | Code.require_file("support/#{file}", __DIR__) 7 | end) 8 | 9 | ExUnit.start() 10 | --------------------------------------------------------------------------------