= {
Haskell: 'magenta',
Swift: 'red',
JVM: 'blue',
+ 'Elixir (0.21+)': 'magenta',
'GitHub Actions': 'comment',
'Forgejo Actions': 'orange',
'devenv & Nix': 'cyan',
@@ -318,6 +321,21 @@ const languageSdkMetadata = [
.load();
System.out.println(s.secret("DATABASE_URL").get());`,
},
+ {
+ id: 'elixir',
+ icon: 'elixir',
+ label: 'Elixir (0.21+)',
+ href: '/sdk/elixir/',
+ color: consumerTone({ label: 'Elixir (0.21+)', href: '/sdk/elixir/', secrets: [] }),
+ code: `resolved =
+ SecretSpec.builder()
+ |> SecretSpec.Builder.with_provider("keyring://")
+ |> SecretSpec.Builder.with_reason("boot")
+ |> SecretSpec.Builder.load()
+
+resolved.secrets["DATABASE_URL"]
+|> SecretSpec.ResolvedSecret.get()`,
+ },
];
function providerTone(provider: ProviderMetadata) {
@@ -1839,7 +1857,7 @@ Enter access_token for provider 'bws': ****
Language SDKs
Resolve secrets directly from your language.
- One resolver across {sdkCount} SDKs—Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, C#, Swift and JVM. Same profiles. Same providers. SDKs →
+ One resolver across {sdkCount} SDKs—Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, C#, Swift, JVM and Elixir (0.21+). Same profiles. Same providers. SDKs →
diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh
index 9604184ed..fd0f72d51 100755
--- a/scripts/ci-sdks.sh
+++ b/scripts/ci-sdks.sh
@@ -191,6 +191,17 @@ run_jvm() {
( cd secretspec-jvm && ./gradlew build )
}
+run_elixir() {
+ echo "==> Elixir"
+ (
+ cd secretspec-ex
+ mix local.hex --force
+ mix local.rebar --force
+ mix deps.get
+ mix test
+ )
+}
+
run_php() {
echo "==> PHP"
# The PHP SDK has two native backends over the same resolver; exercise both.
@@ -243,6 +254,7 @@ start_suite Haskell run_haskell
start_suite .NET run_dotnet
start_suite JVM run_jvm
start_suite PHP run_php
+start_suite Elixir run_elixir
failed_suites=()
for index in "${!suite_pids[@]}"; do
diff --git a/scripts/sync-sdk-versions.sh b/scripts/sync-sdk-versions.sh
index 8ef347dd0..901da2ac7 100755
--- a/scripts/sync-sdk-versions.sh
+++ b/scripts/sync-sdk-versions.sh
@@ -135,6 +135,16 @@ update_file() {
END { if (!changed) exit 1 }
' "$file" > "$tmp"
;;
+ mix-exs)
+ awk -v version="$workspace_version" '
+ !changed && /^[[:space:]]*version:[[:space:]]*"[^"]+",/ {
+ sub(/version:[[:space:]]*"[^"]+",/, "version: \"" version "\",")
+ changed = 1
+ }
+ { print }
+ END { if (!changed) exit 1 }
+ ' "$file" > "$tmp"
+ ;;
*)
echo "unknown manifest kind: $kind" >&2
rm -f "$tmp"
@@ -153,5 +163,10 @@ update_file secretspec-dotnet/src/SecretSpec/SecretSpec.csproj csproj
update_file secretspec-dotnet/tests/SecretSpec.PackageSmoke/SecretSpec.PackageSmoke.csproj csproj
update_file secretspec-jvm/gradle.properties gradle-properties
update_file Package.swift swift-package
+update_file secretspec-ex/mix.exs mix-exs
+
+if [[ -n "${GITHUB_ENV:-}" ]]; then
+ echo "PROJECT_VERSION=$workspace_version" >> "$GITHUB_ENV"
+fi
echo "synced SDK package versions to $workspace_version"
diff --git a/secretspec-ex/.formatter.exs b/secretspec-ex/.formatter.exs
new file mode 100644
index 000000000..07c11e1d1
--- /dev/null
+++ b/secretspec-ex/.formatter.exs
@@ -0,0 +1,3 @@
+[
+ inputs: ["mix.exs", "config/*.exs", "lib/**/*.ex", "test/**/*.exs"]
+]
diff --git a/secretspec-ex/.gitignore b/secretspec-ex/.gitignore
new file mode 100644
index 000000000..45ed99af2
--- /dev/null
+++ b/secretspec-ex/.gitignore
@@ -0,0 +1,28 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where third-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Temporary files, for example, from tests.
+/tmp/
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+secretspec-*.tar
+
+.mix/
+.hex/
+
+/priv/native/
diff --git a/secretspec-ex/README.md b/secretspec-ex/README.md
new file mode 100644
index 000000000..a6c9b6236
--- /dev/null
+++ b/secretspec-ex/README.md
@@ -0,0 +1,27 @@
+# SecretSpec Elixir SDK
+
+The Elixir SDK (0.21+) is a pure Elixir IPC client for a compatible `secretspec` executable (0.21+). It launches `secretspec serve` directly, negotiates `secretspec.resolver/1`, and resolves exact declared names. No Rust code, NIF, or downloaded native archive is included in the Hex package.
+
+```elixir
+{:ok, session} =
+ SecretSpec.Session.start_link(
+ manifest: "/project/secretspec.toml",
+ profile: "production",
+ scope: "deploy",
+ reason: "start application"
+ )
+
+{:ok, secret} =
+ SecretSpec.Session.get(session, "DATABASE_URL",
+ representation: :value,
+ purpose: %{consumer: "my_app", operation: "connect", host: "db.example.com"}
+ )
+
+SecretSpec.Session.close(session)
+```
+
+`get/3` returns `{:ok, %SecretSpec.Secret{}}`, `{:missing, required}`, `:undeclared`, or `{:error, %SecretSpec.Error{}}`. `purpose` attribution is mandatory. Path results are leases owned by the resolver and must be released with `SecretSpec.Session.release/2` or by closing the session.
+
+For one-shot usage, call `SecretSpec.with_session(options, fun)`. Configure `executable:` when discovery through `PATH` is not appropriate.
+
+Build and test with `mix test`.
diff --git a/secretspec-ex/config/config.exs b/secretspec-ex/config/config.exs
new file mode 100644
index 000000000..becde7693
--- /dev/null
+++ b/secretspec-ex/config/config.exs
@@ -0,0 +1 @@
+import Config
diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex
new file mode 100644
index 000000000..89a299b72
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec.ex
@@ -0,0 +1,21 @@
+defmodule SecretSpec do
+ @moduledoc "Pure Elixir client for the SecretSpec resolver IPC protocol."
+
+ alias SecretSpec.Session
+
+ def with_session(options, fun) when is_function(fun, 1) do
+ with {:ok, session} <- Session.start(options) do
+ try do
+ fun.(session)
+ after
+ try do
+ Session.close(session)
+ catch
+ :exit, {:noproc, _} -> :ok
+ :exit, :noproc -> :ok
+ :exit, _reason -> :ok
+ end
+ end
+ end
+ end
+end
diff --git a/secretspec-ex/lib/secret_spec/application.ex b/secretspec-ex/lib/secret_spec/application.ex
new file mode 100644
index 000000000..80f0c7928
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec/application.ex
@@ -0,0 +1,9 @@
+defmodule SecretSpec.Application do
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [{Task.Supervisor, name: SecretSpec.TaskSupervisor}]
+ Supervisor.start_link(children, strategy: :one_for_one, name: SecretSpec.Supervisor)
+ end
+end
diff --git a/secretspec-ex/lib/secret_spec/codec.ex b/secretspec-ex/lib/secret_spec/codec.ex
new file mode 100644
index 000000000..6a33f0f8d
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec/codec.ex
@@ -0,0 +1,160 @@
+defmodule SecretSpec.Codec do
+ @moduledoc false
+ @absolute_max 1_048_576
+ @max_depth 64
+
+ def encode(message, max_frame_bytes \\ @absolute_max) do
+ body = JSON.encode!(message)
+ size = byte_size(body)
+
+ if size < 2 or size > min(max_frame_bytes, @absolute_max) do
+ {:error, :frame_too_large}
+ else
+ {:ok, <>}
+ end
+ end
+
+ def decode(frame, max_frame_bytes \\ @absolute_max)
+
+ def decode(frame, max_frame_bytes) when is_binary(frame) do
+ if frame != <<>> and :binary.last(frame) == ?\n do
+ decode_body(binary_part(frame, 0, byte_size(frame) - 1), max_frame_bytes)
+ else
+ {:error, :truncated_frame}
+ end
+ end
+
+ def decode(_, _), do: {:error, :truncated_frame}
+ def max_frame_bytes, do: @absolute_max
+
+ defp decode_body(body, max_frame_bytes) do
+ size = byte_size(body)
+
+ cond do
+ size < 2 or size > min(max_frame_bytes, @absolute_max) ->
+ {:error, :frame_too_large}
+
+ not String.valid?(body) ->
+ {:error, :invalid_utf8}
+
+ true ->
+ with :ok <- reject_cr(body),
+ :ok <- duplicate_and_nesting_check(body),
+ {:ok, value} <- JSON.decode(body) do
+ {:ok, value}
+ else
+ {:error, reason} when reason in [:duplicate_key, :nesting_too_deep, :carriage_return] ->
+ {:error, reason}
+
+ _ ->
+ {:error, :invalid_json}
+ end
+ end
+ end
+
+ defp reject_cr(body), do: reject_cr(body, false)
+
+ defp reject_cr(<\\, _char, rest::binary>>, true), do: reject_cr(rest, true)
+ defp reject_cr(<\r, _rest::binary>>, _in_string), do: {:error, :carriage_return}
+ defp reject_cr(<", rest::binary>>, true), do: reject_cr(rest, false)
+ defp reject_cr(<", rest::binary>>, false), do: reject_cr(rest, true)
+ defp reject_cr(<<_char, rest::binary>>, in_string), do: reject_cr(rest, in_string)
+ defp reject_cr(<<>>, _in_string), do: :ok
+
+ defp duplicate_and_nesting_check(body) do
+ with {:ok, rest, _depth} <- parse_value(skip_ws(body), 0),
+ <<>> <- skip_ws(rest) do
+ :ok
+ else
+ {:error, reason} -> {:error, reason}
+ _ -> {:error, :invalid_json}
+ end
+ end
+
+ defp parse_value(<>, depth) when char == ?{ and depth < @max_depth,
+ do: parse_object(skip_ws(rest), depth + 1)
+
+ defp parse_value(<>, depth) when char == ?[ and depth < @max_depth,
+ do: parse_array(skip_ws(rest), depth + 1)
+
+ defp parse_value(<>, depth)
+ when char in [?{, ?[] and depth >= @max_depth,
+ do: {:error, :nesting_too_deep}
+
+ defp parse_value(<\", _rest::binary>> = value, depth) do
+ with {:ok, encoded, rest} <- take_string(value),
+ {:ok, _string} <- JSON.decode(encoded) do
+ {:ok, rest, depth}
+ else
+ _ -> {:error, :invalid_json}
+ end
+ end
+
+ defp parse_value(value, depth) do
+ {atom, rest} = take_atom(value)
+ if atom == "", do: {:error, :invalid_json}, else: {:ok, rest, depth}
+ end
+
+ defp parse_object(<<"}"::binary, rest::binary>>, depth), do: {:ok, rest, depth}
+ defp parse_object(value, depth), do: parse_object_members(value, depth, MapSet.new())
+
+ defp parse_object_members(value, depth, keys) do
+ with {:ok, encoded_key, rest} <- take_string(skip_ws(value)),
+ {:ok, key} <- JSON.decode(encoded_key),
+ {:ok, rest} <- consume_colon(rest),
+ false <- MapSet.member?(keys, key),
+ {:ok, rest, _} <- parse_value(skip_ws(rest), depth) do
+ case skip_ws(rest) do
+ <<",", tail::binary>> -> parse_object_members(tail, depth, MapSet.put(keys, key))
+ <<"}", tail::binary>> -> {:ok, tail, depth}
+ _ -> {:error, :invalid_json}
+ end
+ else
+ true -> {:error, :duplicate_key}
+ _ -> {:error, :invalid_json}
+ end
+ end
+
+ defp parse_array(<<"]"::binary, rest::binary>>, depth), do: {:ok, rest, depth}
+
+ defp parse_array(value, depth) do
+ with {:ok, rest, _} <- parse_value(skip_ws(value), depth) do
+ case skip_ws(rest) do
+ <<",", tail::binary>> -> parse_array(tail, depth)
+ <<"]", tail::binary>> -> {:ok, tail, depth}
+ _ -> {:error, :invalid_json}
+ end
+ end
+ end
+
+ defp consume_colon(binary) do
+ case skip_ws(binary) do
+ <<":", rest::binary>> -> {:ok, rest}
+ _ -> {:error, :invalid_json}
+ end
+ end
+
+ defp take_string(<\", rest::binary>>), do: take_string_chars(rest, <\">>)
+ defp take_string(_), do: {:error, :invalid_json}
+
+ defp take_string_chars(<\", rest::binary>>, acc), do: {:ok, <>, rest}
+
+ defp take_string_chars(<\\, char, rest::binary>>, acc),
+ do: take_string_chars(rest, <>)
+
+ defp take_string_chars(<>, acc),
+ do: take_string_chars(rest, <>)
+
+ defp take_string_chars(<<>>, _acc), do: {:error, :invalid_json}
+
+ defp take_atom(binary), do: take_atom(binary, <<>>)
+
+ defp take_atom(<>, acc) when char in [32, 9, 10, 13, ?,, ?}, ?]],
+ do: {acc, <>}
+
+ defp take_atom(<>, acc), do: take_atom(rest, <>)
+ defp take_atom(<<>>, acc), do: {acc, <<>>}
+
+ defp skip_ws(<>) when char in [32, 9, 10, 13], do: skip_ws(rest)
+ defp skip_ws(binary), do: binary
+end
diff --git a/secretspec-ex/lib/secret_spec/error.ex b/secretspec-ex/lib/secret_spec/error.ex
new file mode 100644
index 000000000..d8b89f520
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec/error.ex
@@ -0,0 +1,12 @@
+defmodule SecretSpec.Error do
+ @moduledoc "A resolver protocol error."
+ defexception [:kind, :message, :data]
+
+ @impl true
+ def message(%__MODULE__{kind: kind, message: message}), do: "#{message} (kind: #{kind})"
+
+ def from_response(%{"error" => %{"message" => message, "data" => %{"kind" => kind}} = error}),
+ do: %__MODULE__{kind: kind, message: message, data: error}
+
+ def from_response(_), do: %__MODULE__{kind: "protocol", message: "malformed error response"}
+end
diff --git a/secretspec-ex/lib/secret_spec/secret.ex b/secretspec-ex/lib/secret_spec/secret.ex
new file mode 100644
index 000000000..528bb9ac2
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec/secret.ex
@@ -0,0 +1,15 @@
+defmodule SecretSpec.Secret do
+ @moduledoc "A single result returned by `resolver.get`."
+ defstruct [
+ :name,
+ :representation,
+ :value,
+ :path,
+ :lease_id,
+ :source,
+ :source_provider,
+ :revision,
+ :expires_at,
+ :refresh_at
+ ]
+end
diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex
new file mode 100644
index 000000000..edd017045
--- /dev/null
+++ b/secretspec-ex/lib/secret_spec/session.ex
@@ -0,0 +1,1097 @@
+defmodule SecretSpec.Session do
+ use GenServer
+
+ alias SecretSpec.{Codec, Error, Secret}
+ @default_startup 5_000
+ @default_timeout 30_000
+ @max_request_timeout 300_000
+ @absolute_max_frame_bytes 1_048_576
+ @min_frame_bytes 4_096
+ @max_in_flight 32
+ @default_max_frame_bytes @absolute_max_frame_bytes
+ @default_max_in_flight 16
+ @protocol "secretspec.resolver"
+ @version 1
+ @process_group_poll_ms 25
+ @process_group_wait_attempts 200
+
+ defstruct port: nil,
+ process_grouped: false,
+ process_groups: [],
+ executable: nil,
+ methods: MapSet.new(),
+ limits: %{
+ max_frame_bytes: @default_max_frame_bytes,
+ max_in_flight: @default_max_in_flight
+ },
+ next_id: 1,
+ pending: %{},
+ callers: %{},
+ waiters: [],
+ leases: MapSet.new(),
+ prompt: nil,
+ status: :starting,
+ options: [],
+ buffer: <<>>,
+ abandoned: MapSet.new(),
+ callbacks: %{},
+ deadlines: %{},
+ last_callback_id: 0,
+ owner_ref: nil
+
+ def start(options), do: GenServer.start(__MODULE__, Keyword.put(options, :owner, self()))
+
+ def start_link(options), do: GenServer.start_link(__MODULE__, options)
+
+ def get(session, name, options \\ []),
+ do:
+ GenServer.call(
+ session,
+ {:get, name, options},
+ Keyword.get(options, :timeout, @default_timeout) + 1_000
+ )
+
+ def release(session, lease_id),
+ do: GenServer.call(session, {:release, lease_id}, @default_timeout + 1_000)
+
+ def close(session), do: GenServer.call(session, :close, @default_timeout + 1_000)
+
+ @impl true
+ def init(options) do
+ Process.flag(:trap_exit, true)
+ executable = Keyword.get(options, :executable) || System.find_executable("secretspec")
+
+ if is_nil(executable) do
+ {:stop, :executable_not_found}
+ else
+ args = Keyword.get(options, :arguments, ["serve"])
+
+ {spawn_executable, spawn_args} = spawn_command(executable, args)
+
+ port =
+ Port.open({:spawn_executable, spawn_executable}, [
+ :binary,
+ :exit_status,
+ {:args, spawn_args}
+ ])
+
+ owner_ref =
+ case Keyword.get(options, :owner) do
+ owner when is_pid(owner) -> Process.monitor(owner)
+ _ -> nil
+ end
+
+ process_grouped = is_binary(System.find_executable("setsid"))
+ process_groups = if process_grouped, do: process_groups_for_port(port), else: []
+
+ state = %__MODULE__{
+ port: port,
+ process_grouped: process_grouped,
+ process_groups: process_groups,
+ executable: executable,
+ limits: offered_limits(options),
+ options: options,
+ prompt: Keyword.get(options, :prompt),
+ owner_ref: owner_ref
+ }
+
+ {:ok, state, {:continue, :initialize}}
+ end
+ end
+
+ @impl true
+ def handle_continue(:initialize, state) do
+ application = initialize_application(state.options)
+ id = state.next_id
+
+ message = %{
+ "jsonrpc" => "2.0",
+ "id" => id,
+ "method" => "rpc.initialize",
+ "_meta" => %{"deadline_unix_ms" => System.system_time(:millisecond) + @default_startup},
+ "params" => %{
+ "protocol" => @protocol,
+ "versions" => [@version],
+ "client" => %{
+ "name" => "secretspec-elixir",
+ "version" => Application.spec(:secretspec, :vsn) |> to_string()
+ },
+ "limits" => state.limits,
+ "client_methods" => if(state.prompt, do: ["client.prompt"], else: []),
+ "application" => application
+ }
+ }
+
+ with :ok <- send_message(state, message) do
+ timer = Process.send_after(self(), {:deadline, id}, @default_startup)
+ pending = Map.put(state.pending, id, {:initialize, nil, timer, nil})
+
+ deadlines =
+ Map.put(state.deadlines, id, System.system_time(:millisecond) + @default_startup)
+
+ {:noreply, %{state | next_id: id + 1, pending: pending, deadlines: deadlines}}
+ else
+ {:error, :port_closed} -> {:stop, :normal, state}
+ {:error, reason} -> {:stop, reason, state}
+ end
+ end
+
+ @impl true
+ def handle_call({:get, name, options}, from, %{status: :starting} = state) do
+ deadline = System.system_time(:millisecond) + Keyword.get(options, :timeout, @default_timeout)
+ {:noreply, %{state | waiters: [{from, {:get, name, options}, deadline} | state.waiters]}}
+ end
+
+ def handle_call({:get, name, options}, from, state) do
+ with :ok <- validate_get(name, options),
+ {:ok, state, _id} <-
+ request(
+ state,
+ "resolver.get",
+ get_params(name, options),
+ from,
+ Keyword.get(options, :timeout, @default_timeout)
+ ) do
+ {:noreply, state}
+ else
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call({:release, lease_id}, from, state) do
+ case request(
+ state,
+ "resolver.release",
+ %{"path_lease_ids" => [lease_id]},
+ from,
+ @default_timeout
+ ) do
+ {:ok, state, _} -> {:noreply, state}
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call(:close, _from, %{status: :starting} = state) do
+ safe_close_port(state.port, state.process_groups, state.process_grouped)
+ reply = {:error, %Error{kind: "session_closed", message: "session closed"}}
+
+ Enum.each(state.waiters, fn {from, _request, _deadline} -> reply_if_present(from, reply) end)
+
+ {:stop, :normal, :ok, %{state | status: :closed, waiters: []}}
+ end
+
+ def handle_call(:close, _from, %{status: :closed} = state), do: {:reply, :ok, state}
+
+ def handle_call(:close, from, state) do
+ case request(state, "rpc.shutdown", %{}, from, @default_timeout) do
+ {:ok, state, _} -> {:noreply, %{state | status: :closing}}
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ end
+
+ @impl true
+ def handle_info({port, {:data, data}}, %{port: port} = state) do
+ handle_data(state.buffer <> data, %{state | buffer: <<>>})
+ end
+
+ def handle_info({port, {:exit_status, status}}, %{port: port} = state),
+ do: fail_all({:error, {:child_exit, status}}, state)
+
+ def handle_info({:EXIT, port, reason}, %{port: port} = state),
+ do: fail_all({:error, {:child_exit, reason}}, state)
+
+ def handle_info({:deadline, id}, state) do
+ case Map.pop(state.pending, id) do
+ {nil, _} ->
+ {:noreply, state}
+
+ {{kind, from, timer, _name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+ send_cancel(state, id)
+
+ reply_if_present(
+ from,
+ {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}}
+ )
+
+ state = cancel_callbacks_for_parent(state, id)
+
+ if kind == :initialize or state.status == :closing do
+ fail_all(
+ {:error,
+ %Error{
+ kind: "deadline_exceeded",
+ message:
+ if(kind == :initialize,
+ do: "session initialization deadline exceeded",
+ else: "shutdown deadline exceeded"
+ )
+ }},
+ %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)}
+ )
+ else
+ state = remove_caller(state, id)
+
+ {:noreply,
+ %{
+ state
+ | pending: pending,
+ deadlines: Map.delete(state.deadlines, id),
+ abandoned: MapSet.put(state.abandoned, id)
+ }}
+ end
+ end
+ end
+
+ def handle_info({:prompt_result, id, answer}, state) do
+ case Map.pop(state.callbacks, id) do
+ {nil, _callbacks} ->
+ {:noreply, state}
+
+ {callback, callbacks} ->
+ if callback.timer, do: Process.cancel_timer(callback.timer)
+
+ response =
+ if callback.deadline > System.system_time(:millisecond) do
+ prompt_response(id, answer)
+ else
+ prompt_error(id, "prompt deadline exceeded")
+ end
+
+ case send_message(state, response) do
+ :ok ->
+ {:noreply, %{state | callbacks: callbacks}}
+
+ {:error, :frame_too_large} ->
+ send_message(state, prompt_too_large_error(id))
+ {:noreply, %{state | callbacks: callbacks}}
+
+ {:error, _reason} ->
+ fail_all(
+ {:error, %Error{kind: "protocol", message: "unable to send prompt response"}},
+ %{state | callbacks: callbacks}
+ )
+ end
+ end
+ end
+
+ def handle_info({:callback_deadline, id}, state) do
+ case Map.pop(state.callbacks, id) do
+ {nil, _callbacks} ->
+ {:noreply, state}
+
+ {callback, callbacks} ->
+ Process.exit(callback.pid, :kill)
+ send_message(state, prompt_error(id, "prompt deadline exceeded"))
+ {:noreply, %{state | callbacks: callbacks}}
+ end
+ end
+
+ def handle_info({:DOWN, ref, :process, _pid, _reason}, %{owner_ref: ref} = state),
+ do: fail_all({:error, :owner_down}, state)
+
+ def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
+ case Map.pop(state.callers, ref) do
+ {nil, _callers} ->
+ {:noreply, state}
+
+ {id, callers} ->
+ case Map.pop(state.pending, id) do
+ {nil, _pending} ->
+ {:noreply, %{state | callers: callers}}
+
+ {{_kind, _from, timer, _name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+ send_cancel(state, id)
+ state = cancel_callbacks_for_parent(state, id)
+
+ {:noreply,
+ %{
+ state
+ | pending: pending,
+ callers: callers,
+ deadlines: Map.delete(state.deadlines, id),
+ abandoned: MapSet.put(state.abandoned, id)
+ }}
+ end
+ end
+ end
+
+ def handle_info(_message, state), do: {:noreply, state}
+
+ defp handle_data(data, state) do
+ frame_limit = frame_limit(state)
+
+ case :binary.match(data, "\n") do
+ :nomatch ->
+ if byte_size(data) > frame_limit do
+ fail_all(
+ {:error, %Error{kind: "protocol", message: "frame exceeds negotiated limit"}},
+ state
+ )
+ else
+ {:noreply, %{state | buffer: data}}
+ end
+
+ {offset, 1} ->
+ body = binary_part(data, 0, offset)
+ tail = binary_part(data, offset + 1, byte_size(data) - offset - 1)
+
+ case Codec.decode(<>, frame_limit) do
+ {:ok, message} ->
+ case handle_message(message, state) do
+ {:noreply, state} -> handle_data(tail, state)
+ other -> other
+ end
+
+ {:error, reason} ->
+ fail_all(
+ {:error, %Error{kind: "protocol", message: "invalid frame: #{reason}"}},
+ state
+ )
+ end
+ end
+ end
+
+ defp handle_message(%{"id" => id, "result" => result}, state) when is_integer(id) do
+ case Map.pop(state.pending, id) do
+ {nil, _} ->
+ if MapSet.member?(state.abandoned, id) do
+ {:noreply, %{state | abandoned: MapSet.delete(state.abandoned, id)}}
+ else
+ fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state)
+ end
+
+ {{:initialize, _from, timer, _name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+
+ with :ok <- validate_initialize(result, state) do
+ state = %{
+ state
+ | pending: pending,
+ deadlines: Map.delete(state.deadlines, id),
+ status: :ready,
+ methods: MapSet.new(result["methods"] || []),
+ limits: normalize_limits(result["limits"], state.limits)
+ }
+
+ waiters = Enum.reverse(state.waiters)
+ state = %{state | waiters: []}
+
+ Enum.each(waiters, fn {from, {:get, name, options}, deadline} ->
+ if deadline > System.system_time(:millisecond) do
+ GenServer.cast(self(), {:retry, from, name, options, deadline})
+ else
+ GenServer.reply(
+ from,
+ {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}}
+ )
+ end
+ end)
+
+ {:noreply, state}
+ else
+ {:error, reason} -> fail_all({:error, reason}, state)
+ end
+
+ {{:request, from, timer, name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+ reply = decode_result(result, name)
+ GenServer.reply(from, reply)
+ state = cancel_callbacks_for_parent(state, id)
+ state = remove_caller(state, id)
+ state = %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)}
+
+ if state.status == :closing do
+ safe_close_port(state.port, state.process_groups, state.process_grouped)
+ {:stop, :normal, %{state | status: :closed}}
+ else
+ {:noreply, state}
+ end
+ end
+ end
+
+ defp handle_message(%{"id" => id, "error" => error}, state) when is_integer(id) do
+ case Map.pop(state.pending, id) do
+ {nil, _} ->
+ if MapSet.member?(state.abandoned, id) do
+ {:noreply, %{state | abandoned: MapSet.delete(state.abandoned, id)}}
+ else
+ fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state)
+ end
+
+ {{:initialize, nil, timer, _name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+
+ fail_all(
+ {:error, Error.from_response(%{"error" => error})},
+ %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)}
+ )
+
+ {{_kind, from, timer, _name}, pending} ->
+ if timer, do: Process.cancel_timer(timer)
+ reply_if_present(from, {:error, Error.from_response(%{"error" => error})})
+ state = cancel_callbacks_for_parent(state, id)
+ state = remove_caller(state, id)
+ {:noreply, %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)}}
+ end
+ end
+
+ defp handle_message(
+ %{
+ "jsonrpc" => "2.0",
+ "method" => "client.prompt",
+ "id" => id,
+ "_meta" => meta,
+ "params" => params
+ } = message,
+ state
+ )
+ when is_integer(id) and is_map(meta) and is_map(params) and map_size(message) == 5 and
+ map_size(meta) == 2 and is_function(state.prompt, 1) do
+ with {:ok, parent_id, deadline} <- validate_callback(meta, id, state),
+ :ok <- validate_prompt_params(params) do
+ parent = self()
+
+ case Task.Supervisor.start_child(SecretSpec.TaskSupervisor, fn ->
+ send(parent, {:prompt_result, id, state.prompt.(params)})
+ end) do
+ {:ok, pid} ->
+ timer =
+ Process.send_after(
+ self(),
+ {:callback_deadline, id},
+ max(deadline - System.system_time(:millisecond), 0)
+ )
+
+ {:noreply,
+ %{
+ state
+ | callbacks:
+ Map.put(state.callbacks, id, %{
+ parent_id: parent_id,
+ pid: pid,
+ deadline: deadline,
+ timer: timer
+ }),
+ last_callback_id: id
+ }}
+
+ {:error, _reason} ->
+ fail_all({:error, %Error{kind: "internal", message: "unable to start prompt"}}, state)
+ end
+ else
+ _ ->
+ fail_all({:error, %Error{kind: "protocol", message: "invalid callback request"}}, state)
+ end
+ end
+
+ defp handle_message(%{"jsonrpc" => "2.0", "method" => method} = message, state)
+ when is_binary(method) do
+ if valid_notification?(message) do
+ {:noreply, state}
+ else
+ fail_all({:error, %Error{kind: "protocol", message: "invalid notification"}}, state)
+ end
+ end
+
+ defp handle_message(_, state),
+ do: fail_all({:error, %Error{kind: "protocol", message: "invalid message"}}, state)
+
+ @impl true
+ def handle_cast({:retry, from, name, options, deadline}, state) do
+ if deadline > System.system_time(:millisecond) do
+ case validate_get(name, options) do
+ :ok ->
+ timeout = deadline - System.system_time(:millisecond)
+
+ case request(state, "resolver.get", get_params(name, options), from, timeout) do
+ {:ok, state, _id} ->
+ {:noreply, state}
+
+ {:error, reason} ->
+ GenServer.reply(from, {:error, reason})
+ {:noreply, state}
+ end
+
+ {:error, reason} ->
+ GenServer.reply(from, {:error, reason})
+ {:noreply, state}
+ end
+ else
+ GenServer.reply(
+ from,
+ {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}}
+ )
+
+ {:noreply, state}
+ end
+ end
+
+ defp request(state, method, params, from, timeout) do
+ cond do
+ method != "rpc.shutdown" and not MapSet.member?(state.methods, method) ->
+ {:error, %Error{kind: "capability_required", message: "method not advertised"}}
+
+ map_size(state.pending) >= state.limits.max_in_flight ->
+ {:error, %Error{kind: "busy", message: "maximum in-flight requests reached"}}
+
+ true ->
+ send_request(state, method, params, from, timeout)
+ end
+ end
+
+ defp send_request(state, method, params, from, timeout) do
+ with {:ok, timeout} <- normalize_timeout(timeout) do
+ id = state.next_id
+ deadline = System.system_time(:millisecond) + timeout
+
+ message = %{
+ "jsonrpc" => "2.0",
+ "id" => id,
+ "method" => method,
+ "_meta" => %{"deadline_unix_ms" => deadline},
+ "params" => params
+ }
+
+ with :ok <- send_message(state, message) do
+ timer = Process.send_after(self(), {:deadline, id}, timeout)
+ caller_ref = Process.monitor(elem(from, 0))
+ pending = Map.put(state.pending, id, {:request, from, timer, Map.get(params, "name")})
+ deadlines = Map.put(state.deadlines, id, deadline)
+
+ {:ok,
+ %{
+ state
+ | next_id: id + 1,
+ pending: pending,
+ deadlines: deadlines,
+ callers: Map.put(state.callers, caller_ref, id)
+ }, id}
+ end
+ end
+ end
+
+ defp normalize_timeout(timeout) when is_integer(timeout) and timeout <= 0,
+ do: {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}}
+
+ defp normalize_timeout(timeout) when is_integer(timeout),
+ do: {:ok, min(timeout, @max_request_timeout)}
+
+ defp normalize_timeout(_),
+ do: {:error, %Error{kind: "invalid_request", message: "request timeout is invalid"}}
+
+ defp remove_caller(state, id) do
+ case Enum.find(state.callers, fn {_ref, caller_id} -> caller_id == id end) do
+ {ref, _id} ->
+ Process.demonitor(ref, [:flush])
+ %{state | callers: Map.delete(state.callers, ref)}
+
+ nil ->
+ state
+ end
+ end
+
+ defp reply_if_present(nil, _reply), do: :ok
+ defp reply_if_present(from, reply), do: GenServer.reply(from, reply)
+
+ defp send_message(state, message) do
+ case Codec.encode(message, frame_limit(state)) do
+ {:ok, frame} ->
+ try do
+ Port.command(state.port, frame)
+ :ok
+ catch
+ :error, :badarg -> {:error, :port_closed}
+ end
+
+ error ->
+ error
+ end
+ end
+
+ defp send_cancel(state, id),
+ do:
+ send_message(state, %{
+ "jsonrpc" => "2.0",
+ "method" => "rpc.cancel",
+ "params" => %{"id" => id}
+ })
+
+ defp fail_all(reply, state) do
+ Enum.each(state.pending, fn
+ {_id, {_kind, nil, timer, _name}} ->
+ if timer, do: Process.cancel_timer(timer)
+
+ {_id, {_kind, from, timer, _name}} ->
+ if timer, do: Process.cancel_timer(timer)
+ reply_if_present(from, reply)
+ end)
+
+ Enum.each(state.waiters, fn {from, _request, _deadline} -> reply_if_present(from, reply) end)
+ Enum.each(state.callers, fn {ref, _id} -> Process.demonitor(ref, [:flush]) end)
+ if state.owner_ref, do: Process.demonitor(state.owner_ref, [:flush])
+ Enum.each(state.callbacks, fn {_id, %{pid: pid}} -> Process.exit(pid, :kill) end)
+ safe_close_port(state.port, state.process_groups, state.process_grouped)
+
+ {:stop, :normal,
+ %{state | pending: %{}, callers: %{}, waiters: [], callbacks: %{}, deadlines: %{}}}
+ end
+
+ defp cancel_callbacks_for_parent(state, parent_id) do
+ callbacks =
+ Enum.reduce(state.callbacks, state.callbacks, fn {callback_id, callback}, callbacks ->
+ if callback.parent_id == parent_id do
+ Process.exit(callback.pid, :kill)
+ Map.delete(callbacks, callback_id)
+ else
+ callbacks
+ end
+ end)
+
+ %{state | callbacks: callbacks}
+ end
+
+ defp valid_notification?(message) do
+ MapSet.new(Map.keys(message)) == MapSet.new(["jsonrpc", "method", "params"]) and
+ message["jsonrpc"] == "2.0" and is_binary(message["method"]) and
+ byte_size(message["method"]) in 1..256 and is_map(message["params"])
+ end
+
+ defp offered_limits(options) do
+ %{
+ max_frame_bytes:
+ options
+ |> Keyword.get(:max_frame_bytes, @default_max_frame_bytes)
+ |> clamp(@min_frame_bytes, @absolute_max_frame_bytes),
+ max_in_flight:
+ options |> Keyword.get(:max_in_flight, @default_max_in_flight) |> clamp(1, @max_in_flight)
+ }
+ end
+
+ defp clamp(value, min, max) when is_integer(value), do: value |> max(min) |> min(max)
+ defp clamp(_value, min, _max), do: min
+
+ defp frame_limit(%{status: :starting}), do: @absolute_max_frame_bytes
+ defp frame_limit(state), do: state.limits.max_frame_bytes
+
+ defp spawn_command(executable, args) do
+ executable = Path.expand(executable)
+
+ case {System.find_executable("setsid"), System.find_executable("sh")} do
+ {setsid, shell} when is_binary(setsid) and is_binary(shell) ->
+ {shell,
+ ["-c", "exec setsid --wait \"$@\" 2>/dev/null", "secretspec-resolver", executable | args]}
+
+ {setsid, _} when is_binary(setsid) ->
+ {setsid, ["--wait", executable | args]}
+
+ _ ->
+ {executable, args}
+ end
+ end
+
+ defp initialize_application(options) do
+ manifest = Keyword.get(options, :manifest)
+
+ %{
+ "manifest" => manifest_value(manifest),
+ "provider" => Keyword.get(options, :provider),
+ "profile" => Keyword.get(options, :profile),
+ "scope" => Keyword.get(options, :scope),
+ "reason" => Keyword.get(options, :reason)
+ }
+ end
+
+ defp manifest_value(path) when is_binary(path),
+ do: %{"kind" => "path", "path" => Path.expand(path)}
+
+ defp manifest_value(%{toml: toml, base_dir: base_dir}),
+ do: %{"kind" => "inline", "toml" => toml, "base_dir" => Path.expand(base_dir)}
+
+ defp manifest_value(_), do: nil
+
+ defp get_params(name, options),
+ do: %{
+ "name" => name,
+ "representation" => Atom.to_string(Keyword.get(options, :representation, :auto)),
+ "purpose" => Keyword.get(options, :purpose) |> normalize_purpose()
+ }
+
+ defp normalize_purpose(purpose) when is_map(purpose),
+ do: Map.new(purpose, fn {k, v} -> {to_string(k), v} end)
+
+ defp validate_get(name, options) when is_binary(name) and byte_size(name) <= 4096 do
+ case Keyword.get(options, :purpose) do
+ %{consumer: consumer, operation: operation}
+ when is_binary(consumer) and is_binary(operation) and byte_size(consumer) > 0 and
+ byte_size(operation) > 0 ->
+ :ok
+
+ %{"consumer" => consumer, "operation" => operation}
+ when is_binary(consumer) and is_binary(operation) ->
+ :ok
+
+ _ ->
+ {:error,
+ %Error{
+ kind: "invalid_request",
+ message: "purpose with consumer and operation is required"
+ }}
+ end
+ end
+
+ defp validate_get(_, _),
+ do: {:error, %Error{kind: "invalid_request", message: "secret name is invalid"}}
+
+ defp normalize_limits(limits, _defaults) do
+ %{
+ max_frame_bytes: limits["max_frame_bytes"],
+ max_in_flight: limits["max_in_flight"]
+ }
+ end
+
+ defp validate_initialize(
+ %{
+ "protocol" => @protocol,
+ "version" => @version,
+ "methods" => methods,
+ "limits" => limits
+ },
+ state
+ )
+ when is_list(methods) and is_map(limits) do
+ cond do
+ not Enum.all?(methods, &is_binary/1) ->
+ {:error, %Error{kind: "protocol", message: "invalid methods"}}
+
+ validate_limits(limits, state.limits) != :ok ->
+ {:error, %Error{kind: "protocol", message: "invalid negotiated limits"}}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp validate_initialize(_, _),
+ do: {:error, %Error{kind: "protocol", message: "invalid initialization response"}}
+
+ defp validate_limits(%{"max_frame_bytes" => frame_bytes, "max_in_flight" => in_flight}, offered)
+ when is_integer(frame_bytes) and is_integer(in_flight) and
+ frame_bytes >= @min_frame_bytes and frame_bytes <= @absolute_max_frame_bytes and
+ in_flight >= 1 and in_flight <= @max_in_flight and
+ frame_bytes <= offered.max_frame_bytes and in_flight <= offered.max_in_flight,
+ do: :ok
+
+ defp validate_limits(_, _), do: {:error, :invalid_limits}
+
+ defp decode_result(%{"status" => "undeclared"}, _name), do: :undeclared
+
+ defp decode_result(%{"status" => "missing", "required" => required}, _name),
+ do: {:missing, required}
+
+ defp decode_result(%{"released" => released}, _name) when is_integer(released), do: :ok
+ defp decode_result(%{} = result, _name) when map_size(result) == 0, do: :ok
+
+ defp decode_result(
+ %{"status" => "resolved", "representation" => "value", "value" => value} = result,
+ name
+ )
+ when is_binary(value) do
+ {:ok, resolved_secret(result, name, value: value)}
+ end
+
+ defp decode_result(
+ %{"status" => "resolved", "representation" => "path", "path" => path} = result,
+ name
+ )
+ when is_binary(path) do
+ lease_id = result["path_lease_id"] || result["lease_id"]
+
+ if is_binary(lease_id) do
+ {:ok, resolved_secret(result, name, path: path, lease_id: lease_id)}
+ else
+ {:error, %Error{kind: "protocol", message: "invalid resolver result: missing path lease"}}
+ end
+ end
+
+ defp decode_result(_result, _name),
+ do: {:error, %Error{kind: "protocol", message: "invalid resolver result"}}
+
+ defp resolved_secret(result, name, fields) do
+ struct(
+ Secret,
+ Map.merge(Map.new(fields), %{
+ name: name,
+ representation: result["representation"],
+ source: result["source"],
+ source_provider: result["source_provider"],
+ revision: result["revision"],
+ expires_at: result["expires_at_unix_ms"],
+ refresh_at: result["refresh_at_unix_ms"]
+ })
+ )
+ end
+
+ defp prompt_response(id, value) when is_binary(value) and byte_size(value) > 0,
+ do: %{"jsonrpc" => "2.0", "id" => id, "result" => %{"value" => value}}
+
+ defp prompt_response(id, :decline), do: prompt_error(id, "prompt declined")
+ defp prompt_response(id, _), do: prompt_error(id, "invalid prompt response")
+
+ defp prompt_error(id, _message),
+ do: %{
+ "jsonrpc" => "2.0",
+ "id" => id,
+ "error" => %{
+ "code" => -32008,
+ "message" => "operation failed",
+ "data" => %{"kind" => "operation_failed", "retryable" => false}
+ }
+ }
+
+ defp prompt_too_large_error(id),
+ do: %{
+ "jsonrpc" => "2.0",
+ "id" => id,
+ "error" => %{
+ "code" => -32009,
+ "message" => "message too large",
+ "data" => %{"kind" => "message_too_large", "retryable" => false}
+ }
+ }
+
+ defp validate_callback(
+ %{"parent_request_id" => parent_id, "deadline_unix_ms" => deadline} = meta,
+ id,
+ state
+ ) do
+ parent_deadline = Map.get(state.deadlines, parent_id)
+
+ if map_size(meta) != 2 do
+ {:error, :invalid_callback}
+ else
+ cond do
+ not is_integer(parent_deadline) or not is_integer(deadline) -> {:error, :invalid_callback}
+ id <= state.last_callback_id -> {:error, :invalid_callback}
+ deadline < System.system_time(:millisecond) -> {:error, :invalid_callback}
+ deadline > parent_deadline -> {:error, :invalid_callback}
+ map_size(state.callbacks) >= state.limits.max_in_flight -> {:error, :invalid_callback}
+ true -> {:ok, parent_id, deadline}
+ end
+ end
+ end
+
+ defp validate_prompt_params(params) when is_map(params) do
+ keys = MapSet.new(Map.keys(params))
+ required = MapSet.new(["name", "profile"])
+ allowed = MapSet.new(["name", "profile", "target_provider"])
+
+ with true <- MapSet.subset?(required, keys),
+ true <- MapSet.subset?(keys, allowed),
+ name when is_binary(name) and byte_size(name) in 1..4096 <- params["name"],
+ profile when is_binary(profile) and byte_size(profile) in 1..4096 <- params["profile"],
+ target when is_binary(target) and byte_size(target) <= 32768 <-
+ Map.get(params, "target_provider", "") do
+ if Map.has_key?(params, "target_provider") or target == "",
+ do: :ok,
+ else: {:error, :invalid_prompt_params}
+ else
+ _ -> {:error, :invalid_prompt_params}
+ end
+ end
+
+ defp process_groups_for_port(port) do
+ case :erlang.port_info(port, :os_pid) do
+ {:os_pid, root} ->
+ {_pids, groups} = await_process_group(root, @process_group_wait_attempts)
+ groups
+
+ nil ->
+ []
+ end
+ end
+
+ defp safe_close_port(port, groups, grouped) when is_port(port) do
+ case :erlang.port_info(port, :os_pid) do
+ {:os_pid, pid} -> terminate_process(pid, groups, grouped)
+ nil -> :ok
+ :undefined -> :ok
+ end
+
+ try do
+ Port.close(port)
+ catch
+ _, _ -> :ok
+ end
+ end
+
+ defp safe_close_port(_, _, _), do: :ok
+
+ defp terminate_process(root, known_groups, grouped) when is_integer(root) do
+ {pids, groups} =
+ if known_groups == [] do
+ if grouped,
+ do: await_process_group(root, @process_group_wait_attempts),
+ else: process_tree(root)
+ else
+ {[], known_groups}
+ end
+
+ descendants = List.delete(pids, root)
+
+ with kill when is_binary(kill) <- System.find_executable("kill") do
+ if groups == [] do
+ terminate_pids(kill, descendants, "TERM")
+ terminate_pids(kill, [root], "TERM")
+ Process.sleep(75)
+
+ {remaining, remaining_groups} = process_tree(root)
+ terminate_groups(kill, remaining_groups, "KILL")
+ terminate_pids(kill, List.delete(remaining, root), "KILL")
+ else
+ terminate_groups(kill, groups, "TERM")
+ Process.sleep(75)
+ terminate_groups(kill, groups, "KILL")
+ end
+
+ terminate_pids(kill, [root], "KILL")
+ wait_for_groups(kill, groups, 20)
+ wait_for_process_tree(kill, root, 20)
+ end
+ rescue
+ _ -> :ok
+ end
+
+ defp terminate_process(_, _, _), do: :ok
+
+ defp await_process_group(root, attempts) do
+ {pids, groups} = process_tree(root)
+
+ cond do
+ groups != [] ->
+ {pids, groups}
+
+ pids == [] ->
+ {pids, groups}
+
+ attempts == 0 ->
+ {pids, groups}
+
+ true ->
+ Process.sleep(25)
+ await_process_group(root, attempts - 1)
+ end
+ end
+
+ defp process_tree(root) do
+ case System.find_executable("ps") do
+ nil ->
+ {[root], []}
+
+ ps ->
+ with {output, 0} <- System.cmd(ps, ["-eo", "pid=,ppid=,pgid="], stderr_to_stdout: true) do
+ rows =
+ output
+ |> String.split("\n", trim: true)
+ |> Enum.flat_map(fn line ->
+ case String.split(String.trim(line), ~r/\s+/) do
+ [pid, ppid, pgid] ->
+ [{String.to_integer(pid), String.to_integer(ppid), String.to_integer(pgid)}]
+
+ _ ->
+ []
+ end
+ end)
+
+ root_group =
+ case Enum.find(rows, fn {pid, _ppid, _pgid} -> pid == root end) do
+ {_pid, _ppid, pgid} -> pgid
+ nil -> nil
+ end
+
+ pids =
+ if Enum.any?(rows, fn {pid, _ppid, _pgid} -> pid == root end) do
+ collect_descendants(root, rows, [])
+ else
+ []
+ end
+
+ groups =
+ rows
+ |> Enum.filter(fn {pid, _ppid, _pgid} -> pid in pids end)
+ |> Enum.map(fn {_pid, _ppid, pgid} -> pgid end)
+ |> Enum.uniq()
+ |> Enum.reject(&(&1 == root_group))
+
+ {pids, groups}
+ else
+ _ -> {[root], []}
+ end
+ end
+ rescue
+ _ -> {[root], []}
+ end
+
+ defp collect_descendants(parent, rows, found) do
+ children =
+ for {pid, ^parent, _pgid} <- rows, pid not in found, do: pid
+
+ case children do
+ [] -> [parent | found]
+ _ -> Enum.reduce(children, [parent | found], &collect_descendants(&1, rows, &2))
+ end
+ end
+
+ defp terminate_groups(kill, groups, signal) do
+ Enum.each(groups, &run_kill(kill, ["-#{signal}", "--", "-#{&1}"]))
+ end
+
+ defp terminate_pids(kill, pids, signal) do
+ Enum.each(pids, &run_kill(kill, ["-#{signal}", Integer.to_string(&1)]))
+ end
+
+ defp wait_for_groups(_kill, [], _attempts), do: :ok
+
+ defp wait_for_groups(_kill, _groups, 0), do: :ok
+
+ defp wait_for_groups(kill, groups, attempts) do
+ if Enum.any?(groups, &process_group_alive?(kill, &1)) do
+ Process.sleep(@process_group_poll_ms)
+ wait_for_groups(kill, groups, attempts - 1)
+ else
+ :ok
+ end
+ end
+
+ defp process_group_alive?(kill, group) do
+ case System.cmd(kill, ["-0", "--", "-#{group}"], stderr_to_stdout: true) do
+ {_output, 0} -> true
+ _ -> false
+ end
+ rescue
+ _ -> false
+ end
+
+ defp wait_for_process_tree(_kill, _root, 0), do: :ok
+
+ defp wait_for_process_tree(kill, root, attempts) do
+ {pids, groups} = process_tree(root)
+
+ if pids == [] do
+ :ok
+ else
+ terminate_groups(kill, groups, "KILL")
+ terminate_pids(kill, pids, "KILL")
+ Process.sleep(25)
+ wait_for_process_tree(kill, root, attempts - 1)
+ end
+ end
+
+ defp run_kill(kill, args) do
+ System.cmd(kill, args, stderr_to_stdout: true)
+ :ok
+ rescue
+ _ -> :ok
+ end
+end
diff --git a/secretspec-ex/mix.exs b/secretspec-ex/mix.exs
new file mode 100644
index 000000000..10eb08eb8
--- /dev/null
+++ b/secretspec-ex/mix.exs
@@ -0,0 +1,37 @@
+defmodule SecretSpec.MixProject do
+ use Mix.Project
+
+ @source_url "https://github.com/cachix/secretspec"
+
+ def project do
+ [
+ app: :secretspec,
+ version: "0.21.0",
+ elixir: "~> 1.19",
+ start_permanent: Mix.env() == :prod,
+ description: "Elixir SDK for SecretSpec",
+ source_url: @source_url,
+ homepage_url: @source_url,
+ docs: [main: "SecretSpec", extras: ["README.md"]],
+ package: package(),
+ deps: deps(),
+ compilers: Mix.compilers()
+ ]
+ end
+
+ def application do
+ [mod: {SecretSpec.Application, []}, extra_applications: [:logger]]
+ end
+
+ defp package do
+ [
+ files: ["lib", "mix.exs", "README.md"],
+ licenses: ["MIT"],
+ links: %{"GitHub" => @source_url}
+ ]
+ end
+
+ defp deps do
+ [{:ex_doc, "~> 0.40.3", only: :dev, runtime: false}]
+ end
+end
diff --git a/secretspec-ex/mix.lock b/secretspec-ex/mix.lock
new file mode 100644
index 000000000..09fcb2cc5
--- /dev/null
+++ b/secretspec-ex/mix.lock
@@ -0,0 +1,8 @@
+%{
+ "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"},
+ "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
+ "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
+ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
+ "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+}
diff --git a/secretspec-ex/test/secret_spec/codec_test.exs b/secretspec-ex/test/secret_spec/codec_test.exs
new file mode 100644
index 000000000..2458065e1
--- /dev/null
+++ b/secretspec-ex/test/secret_spec/codec_test.exs
@@ -0,0 +1,71 @@
+defmodule SecretSpec.CodecTest do
+ use ExUnit.Case, async: true
+ alias SecretSpec.{Codec, Error}
+
+ test "encodes UTF-8 JSON with exactly one LF" do
+ assert {:ok, frame} = Codec.encode(%{"message" => "café"})
+ assert String.ends_with?(frame, "\n")
+ refute String.ends_with?(frame, "\n\n")
+ assert {:ok, %{"message" => "café"}} = Codec.decode(frame)
+ end
+
+ test "validates framing boundaries" do
+ assert {:error, :truncated_frame} = Codec.decode(<<>>)
+ assert {:error, :frame_too_large} = Codec.decode("\n")
+ assert {:error, :truncated_frame} = Codec.decode("{}")
+ assert {:error, :invalid_json} = Codec.decode("{}\n{}\n")
+ assert {:error, :carriage_return} = Codec.decode("{}\r\n")
+ assert {:error, :carriage_return} = Codec.decode("{\"x\":\"a\rb\"}\n")
+ assert {:error, :carriage_return} = Codec.decode("{\"x\":\"a\\rb\"}\r\n")
+ assert {:error, :invalid_json} = Codec.decode("{\"x\":\"a\nb\"}\n")
+ assert {:error, :truncated_frame} = Codec.decode(:not_binary)
+ end
+
+ test "enforces body limits and the absolute ceiling" do
+ body = ~s({"x":"12345"})
+ assert {:ok, _} = Codec.decode(body <> "\n", byte_size(body))
+ assert {:error, :frame_too_large} = Codec.decode(body <> "x\n", byte_size(body))
+ huge = "{" <> "\"x\":\"" <> String.duplicate("x", 1_048_576) <> "\"}\n"
+ assert {:error, :frame_too_large} = Codec.decode(huge, 9_999_999)
+ end
+
+ test "rejects duplicate keys, invalid JSON and invalid UTF-8" do
+ cases = [
+ {~s({"x":1,"x":2}), :duplicate_key},
+ {~s({"x":{"y":1,"y":2}}), :invalid_json},
+ {~s({"x":1,}), :invalid_json},
+ {~s({"x":true), :invalid_json},
+ {~s({"x":true), :invalid_json},
+ {<<"{\"x\":\"", 0xFF, "\"}">>, :invalid_utf8}
+ ]
+
+ for {body, error} <- cases, do: assert({:error, ^error} = Codec.decode(body <> "\n"))
+ assert {:ok, _} = Codec.decode(~s({"a":1,"b":{"a":2}}) <> "\n")
+ assert {:error, :duplicate_key} = Codec.decode(~s({"a":1,"\\u0061":2}) <> "\n")
+ end
+
+ test "accepts nesting depth 64 and rejects depth 65" do
+ accepted = String.duplicate("[", 64) <> "0" <> String.duplicate("]", 64)
+ rejected = String.duplicate("[", 65) <> "0" <> String.duplicate("]", 65)
+ assert {:ok, _} = Codec.decode(accepted <> "\n")
+ assert {:error, :nesting_too_deep} = Codec.decode(rejected <> "\n")
+ end
+
+ test "decodes structured and fallback errors" do
+ response = %{
+ "error" => %{"message" => "deadline", "data" => %{"kind" => "deadline_exceeded", "x" => 1}}
+ }
+
+ assert %Error{
+ kind: "deadline_exceeded",
+ message: "deadline",
+ data: %{
+ "message" => "deadline",
+ "data" => %{"kind" => "deadline_exceeded", "x" => 1}
+ }
+ } =
+ Error.from_response(response)
+
+ assert %Error{kind: "protocol"} = Error.from_response(%{})
+ end
+end
diff --git a/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs b/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs
new file mode 100644
index 000000000..63c1ac7aa
--- /dev/null
+++ b/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs
@@ -0,0 +1,101 @@
+defmodule SecretSpec.SessionDeadlineCallbackTest do
+ use ExUnit.Case, async: true
+ alias SecretSpec.{Error, Session}
+ alias SecretSpec.Test.FakeResolver
+
+ defp request_seen?(messages, method), do: Enum.any?(messages, &(&1["method"] == method))
+ defp cancel_count(messages), do: Enum.count(messages, &(&1["method"] == "rpc.cancel"))
+
+ @tag :tmp_dir
+ test "deadline sends one cancellation after initialization", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, no_response: "resolver.get")
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ assert :ok = FakeResolver.wait_ready(fake)
+
+ assert {:error, %Error{kind: "deadline_exceeded"}} =
+ Session.get(session, "TOKEN",
+ timeout: 50,
+ purpose: %{consumer: "test", operation: "read"}
+ )
+
+ assert :ok =
+ FakeResolver.wait_for(
+ fake,
+ &(request_seen?(&1, "resolver.get") and cancel_count(&1) == 1)
+ )
+
+ assert Process.alive?(session)
+ assert :ok = Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "caller death cancels only its pending request", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, no_response: "resolver.get")
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ assert :ok = FakeResolver.wait_ready(fake)
+ parent = self()
+
+ caller =
+ spawn(fn ->
+ send(parent, {:caller, self()})
+
+ Session.get(session, "TOKEN",
+ timeout: 5_000,
+ purpose: %{consumer: "test", operation: "read"}
+ )
+ end)
+
+ assert_receive {:caller, ^caller}, 1_000
+ assert :ok = FakeResolver.wait_for(fake, &request_seen?(&1, "resolver.get"))
+ ref = Process.monitor(caller)
+ Process.exit(caller, :kill)
+ assert_receive {:DOWN, ^ref, :process, ^caller, _}, 1_000
+ assert :ok = FakeResolver.wait_for(fake, &(cancel_count(&1) == 1))
+ assert Process.alive?(session)
+ assert :ok = Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "late responses do not satisfy another caller", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, delay: 150)
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ assert :ok = FakeResolver.wait_ready(fake)
+
+ assert {:error, %Error{kind: "deadline_exceeded"}} =
+ Session.get(session, "ONE",
+ timeout: 30,
+ purpose: %{consumer: "test", operation: "read"}
+ )
+
+ assert :ok =
+ FakeResolver.wait_for(
+ fake,
+ &(request_seen?(&1, "resolver.get") and cancel_count(&1) == 1)
+ )
+
+ assert {:ok, _} = Session.get(session, "TWO", purpose: %{consumer: "test", operation: "read"})
+ assert :ok = Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "prompt callback is consumed before the parent request completes", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, send_callback: true)
+ parent = self()
+
+ prompt = fn params ->
+ send(parent, {:prompt, params})
+ "yes"
+ end
+
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake, prompt: prompt))
+ assert :ok = FakeResolver.wait_ready(fake)
+
+ assert {:ok, _} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ assert_receive {:prompt, %{"name" => "TOKEN", "profile" => "default"}}, 1_000
+ messages = FakeResolver.messages(fake)
+ assert Enum.any?(messages, &(&1["id"] == 100 and get_in(&1, ["result", "value"]) == "yes"))
+ assert :ok = Session.close(session)
+ end
+end
diff --git a/secretspec-ex/test/secret_spec/session_lifecycle_test.exs b/secretspec-ex/test/secret_spec/session_lifecycle_test.exs
new file mode 100644
index 000000000..91025b549
--- /dev/null
+++ b/secretspec-ex/test/secret_spec/session_lifecycle_test.exs
@@ -0,0 +1,103 @@
+defmodule SecretSpec.SessionLifecycleTest do
+ use ExUnit.Case, async: true
+ alias SecretSpec.Session
+ alias SecretSpec.Test.FakeResolver
+
+ @tag :tmp_dir
+ test "close during initialization terminates the session", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, hang: "rpc.initialize")
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ assert :ok = Session.close(session)
+ assert :ok = wait_until(fn -> not Process.alive?(session) end, 2_000)
+ end
+
+ @tag :tmp_dir
+ test "resolver crash fails pending callers", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, exit: "resolver.get")
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+
+ assert {:error, _} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ assert :ok = wait_until(fn -> not Process.alive?(session) end, 2_000)
+ end
+
+ @tag :tmp_dir
+ test "sessions remain isolated", %{tmp_dir: dir} do
+ fake_a = FakeResolver.build(dir, id: "a", get: "a")
+ fake_b = FakeResolver.build(dir, id: "b", get: "b")
+ {:ok, a} = Session.start_link(FakeResolver.session_options(fake_a))
+ {:ok, b} = Session.start_link(FakeResolver.session_options(fake_b))
+
+ assert {:ok, %{value: "a"}} =
+ Session.get(a, "A", purpose: %{consumer: "test", operation: "read"})
+
+ assert {:ok, %{value: "b"}} =
+ Session.get(b, "B", purpose: %{consumer: "test", operation: "read"})
+
+ Session.close(a)
+ assert Process.alive?(b)
+ Session.close(b)
+ end
+
+ @tag :tmp_dir
+ test "owner termination closes an unlinked session", %{tmp_dir: dir} do
+ fake = FakeResolver.build(dir, hang: "rpc.initialize")
+ parent = self()
+
+ owner =
+ spawn(fn ->
+ {:ok, session} = Session.start(FakeResolver.session_options(fake))
+ send(parent, {:session, session})
+ Process.sleep(:infinity)
+ end)
+
+ session =
+ receive do
+ {:session, pid} -> pid
+ after
+ 2_000 -> flunk("session not started")
+ end
+
+ ref = Process.monitor(session)
+ Process.exit(owner, :kill)
+ assert_receive {:DOWN, ^ref, :process, ^session, _}, 2_000
+ end
+
+ @tag :os_process
+ @tag timeout: 120_000
+ @tag :tmp_dir
+ test "repeated session cycles leave no marked descendants", %{tmp_dir: dir} do
+ marker = "secretspec-test-#{System.unique_integer([:positive])}"
+
+ for n <- 1..25 do
+ fake = FakeResolver.build(dir, id: "cycle-#{n}", marker: marker)
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+
+ assert {:ok, _} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ assert :ok = Session.close(session)
+ end
+
+ assert :ok = wait_until(fn -> FakeResolver.marked_processes(marker) == [] end, 5_000)
+ end
+
+ defp wait_until(fun, timeout) do
+ deadline = System.monotonic_time(:millisecond) + timeout
+ poll_until(fun, deadline)
+ end
+
+ defp poll_until(fun, deadline) do
+ if fun.() do
+ :ok
+ else
+ if System.monotonic_time(:millisecond) < deadline do
+ Process.sleep(25)
+ poll_until(fun, deadline)
+ else
+ {:error, :timeout}
+ end
+ end
+ end
+end
diff --git a/secretspec-ex/test/secret_spec/session_protocol_test.exs b/secretspec-ex/test/secret_spec/session_protocol_test.exs
new file mode 100644
index 000000000..2d83e86e1
--- /dev/null
+++ b/secretspec-ex/test/secret_spec/session_protocol_test.exs
@@ -0,0 +1,129 @@
+defmodule SecretSpec.SessionProtocolTest do
+ use ExUnit.Case, async: true
+ alias SecretSpec.{Error, Secret, Session}
+ alias SecretSpec.Test.FakeResolver
+
+ defp start(tmp_dir, opts) do
+ fake = FakeResolver.build(tmp_dir, opts)
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ {session, fake}
+ end
+
+ @tag :tmp_dir
+ test "initializes and resolves inline, path, missing, and undeclared results", %{tmp_dir: dir} do
+ {session, fake} = start(dir, get: "value")
+
+ assert {:ok, %Secret{representation: "value", value: "value"}} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ assert :ok = Session.close(session)
+
+ assert [
+ %{"method" => "rpc.initialize"},
+ %{"method" => "resolver.get"},
+ %{"method" => "rpc.shutdown"}
+ ] = FakeResolver.messages(fake)
+
+ for result <- [:path, :missing, :optional_missing, :undeclared] do
+ fake = FakeResolver.build(dir, id: "#{result}", get: result)
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+
+ expected =
+ case result do
+ :path ->
+ {:ok,
+ %Secret{
+ name: "TOKEN",
+ representation: "path",
+ path: "/tmp/secret",
+ lease_id: "lease-1"
+ }}
+
+ :missing ->
+ {:missing, true}
+
+ :optional_missing ->
+ {:missing, false}
+
+ :undeclared ->
+ :undeclared
+ end
+
+ assert expected ==
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ Session.close(session)
+ end
+ end
+
+ @tag :tmp_dir
+ test "requires purpose and reports malformed results", %{tmp_dir: dir} do
+ {session, _} = start(dir, get: :malformed)
+ assert {:error, %Error{kind: "invalid_request"}} = Session.get(session, "TOKEN")
+
+ assert {:error, %Error{kind: "protocol"}} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "supports fragmented and coalesced protocol frames", %{tmp_dir: dir} do
+ {session, _fake} = start(dir, chunks: [1, 2, 3, 5, 8, 13])
+
+ assert {:ok, %Secret{value: "secret"}} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ assert :ok = Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "processes a fatal second frame from one port write", %{tmp_dir: dir} do
+ {session, fake} = start(dir, coalesced: true, coalesced_fatal: true)
+ assert :ok = FakeResolver.wait_ready(fake)
+ ref = Process.monitor(session)
+ assert_receive {:DOWN, ^ref, :process, ^session, _}, 2_000
+ end
+
+ @tag :tmp_dir
+ test "negotiated limits and methods are accepted", %{tmp_dir: dir} do
+ init = %{
+ "protocol" => "secretspec.resolver",
+ "version" => 1,
+ "server" => %{"name" => "fake", "version" => "1"},
+ "methods" => ["resolver.get"],
+ "limits" => %{"max_frame_bytes" => 32_768, "max_in_flight" => 1},
+ "application" => %{"manifest_kind" => "path"}
+ }
+
+ {session, _} = start(dir, init: init)
+
+ assert {:ok, %Secret{}} =
+ Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"})
+
+ Session.close(session)
+ end
+
+ @tag :tmp_dir
+ test "invalid initialization response closes the session", %{tmp_dir: dir} do
+ for init <- [
+ %{"protocol" => "wrong"},
+ %{"protocol" => "secretspec.resolver", "version" => 99},
+ %{"protocol" => "secretspec.resolver", "version" => 1, "server" => "bad"}
+ ] do
+ fake = FakeResolver.build(dir, id: "bad#{System.unique_integer()}", init: init)
+ {:ok, session} = Session.start_link(FakeResolver.session_options(fake))
+ assert eventually_dead?(session)
+ end
+ end
+
+ defp eventually_dead?(pid) do
+ ref = Process.monitor(pid)
+
+ receive do
+ {:DOWN, ^ref, :process, ^pid, _} -> true
+ after
+ 2_000 -> false
+ end
+ end
+end
diff --git a/secretspec-ex/test/support/fake_resolver.ex b/secretspec-ex/test/support/fake_resolver.ex
new file mode 100644
index 000000000..9787ae829
--- /dev/null
+++ b/secretspec-ex/test/support/fake_resolver.ex
@@ -0,0 +1,130 @@
+defmodule SecretSpec.Test.FakeResolver do
+ @moduledoc "Isolated executable resolver endpoints for deterministic IPC tests."
+
+ def build(tmp_dir, opts \\ []) do
+ id = opts[:id] || "#{System.unique_integer([:positive])}"
+ transcript = Path.join(tmp_dir, "#{id}.transcript")
+ ready = Path.join(tmp_dir, "#{id}.ready")
+ endpoint = Path.join(tmp_dir, "#{id}.exs")
+ config = Keyword.merge([transcript: transcript, ready: ready, methods: ["resolver.get", "resolver.release", "rpc.shutdown"]], opts)
+ File.write!(endpoint, script(config))
+ %{endpoint: endpoint, transcript: transcript, ready: ready, marker: opts[:marker], executable: System.find_executable("elixir"), arguments: [endpoint]}
+ end
+
+ def session_options(fake, opts \\ []), do: [executable: fake.executable, arguments: fake.arguments] ++ opts
+
+ def messages(fake) do
+ case File.read(fake.transcript) do
+ {:ok, body} -> body |> String.split("\n", trim: true) |> Enum.map(&JSON.decode!/1)
+ {:error, :enoent} -> []
+ end
+ end
+
+ def wait_ready(fake, timeout \\ 2_000), do: wait_for(fake, fn _ -> File.exists?(fake.ready) end, timeout)
+
+ def wait_for(fake, fun, timeout \\ 1_000) do
+ deadline = System.monotonic_time(:millisecond) + timeout
+ do_wait(fake, fun, deadline)
+ end
+
+ defp do_wait(fake, fun, deadline) do
+ if fun.(messages(fake)) or (fake.ready && File.exists?(fake.ready) && fun.([])) do
+ :ok
+ else
+ if System.monotonic_time(:millisecond) < deadline do
+ Process.sleep(10)
+ do_wait(fake, fun, deadline)
+ else
+ {:error, :timeout}
+ end
+ end
+ end
+
+ def marked_processes(marker) do
+ case System.cmd("ps", ["-eo", "args"], stderr_to_stdout: true) do
+ {output, 0} -> output |> String.split("\n") |> Enum.filter(&String.contains?(&1, marker))
+ _ -> []
+ end
+ end
+
+ defp script(config) do
+ """
+ config = #{inspect(config, limit: :infinity, printable_limit: :infinity)}
+ transcript = config[:transcript]
+ write = fn message -> File.write!(transcript, JSON.encode!(message) <> <<10>>, [:append]) end
+ frame = fn message -> JSON.encode!(message) <> <<10>> end
+ response = fn request ->
+ id = request["id"]
+ case request["method"] do
+ "rpc.initialize" ->
+ result = config[:init] || %{"protocol" => "secretspec.resolver", "version" => 1,
+ "server" => %{"name" => "fake", "version" => "1"}, "methods" => config[:methods],
+ "limits" => %{"max_frame_bytes" => 32_768, "max_in_flight" => 1},
+ "application" => %{"manifest_kind" => "path"}}
+ %{"jsonrpc" => "2.0", "id" => id, "result" => result}
+ "resolver.get" ->
+ case config[:get] || :resolved do
+ :missing -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "missing", "required" => true}}
+ :optional_missing -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "missing", "required" => false}}
+ :undeclared -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "undeclared"}}
+ :malformed -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "resolved", "representation" => "unknown"}}
+ :path -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "resolved", "representation" => "path", "path" => "/tmp/secret", "lease_id" => "lease-1"}}
+ value when is_binary(value) -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "resolved", "representation" => "value", "value" => value}}
+ _ -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"status" => "resolved", "representation" => "value", "value" => "secret"}}
+ end
+ "resolver.release" -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{}}
+ "rpc.shutdown" -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{}}
+ _ -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32601, "message" => "method not found", "data" => %{"kind" => "capability"}}}
+ end
+ end
+ emit = fn message -> IO.binwrite(:stdio, frame.(message)) end
+ emit_coalesced = fn messages -> IO.binwrite(:stdio, Enum.map_join(messages, "", frame)) end
+ if config[:marker] do
+ Port.open({:spawn_executable, "/bin/sh"}, [:binary, {:args, ["-c", "exec -a #{config[:marker]} sleep 60"]}])
+ end
+ loop = fn loop, buffer, waiting ->
+ case IO.read(:stdio, 1) do
+ :eof -> :ok
+ data ->
+ buffer = buffer <> data
+ case String.split(buffer, <<10>>, parts: 2) do
+ [rest] -> loop.(loop, rest, waiting)
+ [line, rest] ->
+ request = JSON.decode!(line)
+ write.(request)
+ cond do
+ request["method"] == "rpc.cancel" ->
+ loop.(loop, rest, waiting)
+ waiting && request["id"] == waiting.callback_id && Map.has_key?(request, "result") ->
+ emit.(response.(waiting.parent))
+ loop.(loop, rest, nil)
+ config[:hang] == request["method"] -> Process.sleep(:infinity)
+ config[:exit] == request["method"] -> System.halt(0)
+ true ->
+ if is_integer(config[:delay]) and config[:delay] > 0, do: Process.sleep(config[:delay])
+ if request["method"] == "rpc.initialize" do
+ File.write!(config[:ready], "ready")
+ if config[:coalesced] do
+ emit_coalesced.([response.(request), if(config[:coalesced_fatal], do: %{"jsonrpc" => "2.0", "method" => "resolver.ready"}, else: %{"jsonrpc" => "2.0", "method" => "resolver.ready", "params" => %{}})])
+ else
+ emit.(response.(request))
+ end
+ loop.(loop, rest, waiting)
+ else
+ if config[:send_callback] && request["method"] == "resolver.get" do
+ callback = %{"jsonrpc" => "2.0", "id" => config[:callback_id] || 100, "method" => "client.prompt", "_meta" => %{"parent_request_id" => request["id"], "deadline_unix_ms" => System.system_time(:millisecond) + 1_000}, "params" => %{"name" => "TOKEN", "profile" => "default"}}
+ emit.(callback)
+ loop.(loop, rest, %{parent: request, callback_id: config[:callback_id] || 100})
+ else
+ if config[:no_response] != request["method"], do: emit.(response.(request))
+ loop.(loop, rest, waiting)
+ end
+ end
+ end
+ end
+ end
+ end
+ loop.(loop, "", nil)
+ """
+ end
+end
diff --git a/secretspec-ex/test/test_helper.exs b/secretspec-ex/test/test_helper.exs
new file mode 100644
index 000000000..c28500474
--- /dev/null
+++ b/secretspec-ex/test/test_helper.exs
@@ -0,0 +1,2 @@
+ExUnit.start()
+Code.require_file("support/fake_resolver.ex", __DIR__)
diff --git a/secretspec/README.md b/secretspec/README.md
index f1012e951..9dc7500b2 100644
--- a/secretspec/README.md
+++ b/secretspec/README.md
@@ -321,6 +321,7 @@ out-of-process client and provider communication in 0.21+.
- [C# (0.16+)](https://secretspec.dev/sdk/csharp) (P/Invoke with native assets in the NuGet package)
- [Swift (0.18+)](https://secretspec.dev/sdk/swift) (SwiftPM XCFramework for macOS Intel and Apple silicon)
- [JVM languages (0.20+)](https://secretspec.dev/sdk/jvm) (via JNA with native assets in the JAR)
+- [Elixir (0.21+)](https://secretspec.dev/sdk/elixir) (pure Elixir IPC client)
```python
from secretspec import SecretSpec