diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml new file mode 100644 index 000000000..d8e30394d --- /dev/null +++ b/.github/workflows/elixir-package.yml @@ -0,0 +1,59 @@ +name: Elixir SDK + +on: + pull_request: + paths: + - "secretspec-ex/**" + - ".github/workflows/elixir-package.yml" + push: + tags: ["v**"] + workflow_call: + inputs: + pre_release: + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + package: + name: Build and test Elixir package + if: >- + inputs.pre_release || github.event_name != 'pull_request' || + !startsWith(github.event.pull_request.title, 'Release ') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + elixir-version: "1.19" + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Install Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + - name: Build SecretSpec CLI executable + run: cargo build --release --bin secretspec --features cli + - name: Fetch Elixir dependencies + working-directory: secretspec-ex + run: mix deps.get + - name: Check formatting + working-directory: secretspec-ex + run: mix format --check-formatted + - name: Run tests + working-directory: secretspec-ex + run: mix test + - name: Build docs + working-directory: secretspec-ex + run: mix docs + - name: Validate package + working-directory: secretspec-ex + run: mix hex.build diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index a29cda5dc..88942e612 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -21,6 +21,8 @@ on: - "secretspec-dotnet/src/SecretSpec/SecretSpec.csproj" - "secretspec-jvm/gradle.properties" - "Package.swift" + - "secretspec-ex/**" + - ".github/workflows/elixir-package.yml" permissions: contents: read @@ -83,6 +85,15 @@ jobs: pre_release: true publish: false + elixir: + name: Elixir SDK + needs: authorize + permissions: + contents: read + uses: ./.github/workflows/elixir-package.yml + with: + pre_release: true + python: name: Python wheels needs: authorize diff --git a/.github/workflows/sdks.yml b/.github/workflows/sdks.yml index 40edf4743..80da6a0fb 100644 --- a/.github/workflows/sdks.yml +++ b/.github/workflows/sdks.yml @@ -2,7 +2,7 @@ name: "SDKs" # Runs every language SDK's full test suite (unit + cross-language conformance + # the schema/quicktype codegen pipeline) against a freshly built cdylib, so the -# Python/Go/Ruby/Node/Haskell/C#/PHP/Swift bindings cannot silently rot. +# Python/Go/Ruby/Node/Haskell/C#/PHP/Swift/Elixir bindings cannot silently rot. on: workflow_call: diff --git a/CHANGELOG.md b/CHANGELOG.md index b6518eb96..e5161990e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Replaced the Elixir SDK's embedded native resolver with a pure Elixir `secretspec.resolver/1` IPC client. The 0.21+ package launches `secretspec serve` directly and requires a compatible SecretSpec executable. - IPC resolution exposes optional non-secret revision tokens for downstream task cache invalidation (0.21+). AWS Secrets Manager reports value-bound revisions, @@ -237,6 +238,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 under profile `prd` names the same secret as `doppler://myapp/prd`, and a cache at that pairing would overwrite or delete the secret it caches (0.21+). +- **Elixir SDK (0.21+)** (`secretspec-ex`) is now a pure Elixir client for `secretspec.resolver/1`. It launches `secretspec serve` directly and requires a compatible SecretSpec executable instead of shipping native code or NIF archives. + ### Fixed - IPC rejects incompatible secret representations before contacting providers, diff --git a/conformance/README.md b/conformance/README.md index 92eacada8..69f705b93 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,7 +1,8 @@ # Cross-language conformance suite -Every SecretSpec SDK (Python, Go, Ruby, Node.js, Haskell, C#, PHP, and Swift -(0.18+)) is a thin client over the same resolver contract. This suite proves +Every SecretSpec SDK (Python, Go, Ruby, Node.js, Haskell, C#, PHP, Swift +(0.18+), JVM (0.20+), and Elixir (0.21+)) is a thin client over the same +resolver contract. This suite proves they agree: each SDK resolves the same fixtures and must produce the identical **canonical** result. @@ -65,4 +66,5 @@ relative to the repo root: `swift test --filter SecretSpecTests.testCrossLanguageConformance` after staging the local XCFramework as described in `secretspec-swift/README.md` - JVM (0.20+): - `cd secretspec-jvm && ./gradlew test` \ No newline at end of file + `cd secretspec-jvm && ./gradlew test` +- Elixir (0.21+): `cd secretspec-ex && mix test test/conformance_test.exs` \ No newline at end of file diff --git a/conformance/run.sh b/conformance/run.sh index f6faa7c39..e1f58b70b 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -101,6 +101,13 @@ run_jvm() { ( cd secretspec-jvm ./gradlew test ); } +run_elixir() { ( + cd secretspec-ex + mix local.hex --force + mix local.rebar --force + mix deps.get + mix test +); } run "Python" python run_python run "Go" go run_go @@ -117,6 +124,7 @@ else fi # The gradle wrapper will download gradle, use "bash" as a placeholder run "JVM" bash run_jvm +run "Elixir" mix run_elixir echo echo "==> Conformance summary" diff --git a/devenv.nix b/devenv.nix index b93f28b4b..1173df53e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,4 +1,11 @@ -{ lib, pkgs, ... }: { +{ lib, pkgs, ... }: +let + # https://nixos.org/manual/nixpkgs/stable/#beam-structure + beamPackages = pkgs.beam29Packages.overrideScope (self: super: { elixir = self.elixir_1_20; }); + erlang = beamPackages.erlang; + elixir = beamPackages.elixir; +in +{ languages.rust = { enable = true; # The Rust version is pinned in rust-toolchain.toml, which the native CI @@ -69,6 +76,10 @@ enable = true; jdk.package = pkgs.jdk21; }; + languages.elixir = { + enable = true; + package = elixir; + }; packages = [ # documentation link validation @@ -106,6 +117,8 @@ pkgs.ninja # Installs the libsecretspec archive with its header and pkg-config file pkgs.cargo-c + # expert-lsp needs the erlang binary + erlang ]; env = { diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 1a7c4610e..253cd7f0b 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -434,6 +434,11 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil slug: "sdk/jvm", badge: { text: "0.20+", variant: "note" }, }, + { + label: "Elixir", + slug: "sdk/elixir", + badge: { text: "0.21+", variant: "note" }, + }, ], }, { diff --git a/docs/src/content/docs/development/sdks.mdx b/docs/src/content/docs/development/sdks.mdx index 7e007b0fb..9599b34b5 100644 --- a/docs/src/content/docs/development/sdks.mdx +++ b/docs/src/content/docs/development/sdks.mdx @@ -6,7 +6,7 @@ description: How the language SDKs are built, packaged, and released, and which import VersionCompatibility from '@cachix/site-kit/ui/VersionCompatibility.astro'; SecretSpec ships SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, -PHP, C#, Swift (0.18+), and JVM languages (0.20+). This page is for +PHP, C#, Swift (0.18+), JVM languages (0.20+), and Elixir (0.21+). This page is for contributors: how the SDKs are put together, how each one is packaged and released, which platforms each artifact covers, and what to update when adding a platform or a new SDK. For the user-facing architecture and API, see the @@ -73,6 +73,11 @@ platform and publishes on a version tag: | Swift (0.18+) | SwiftPM source package + XCFramework release asset | `swift-package.yml` | | PHP | Composer package (source) + prebuilt extension binaries and `libsecretspec` release assets | `php-ext.yml`, `ffi-build.yml` | | Haskell | `secretspec` on Hackage (source) | `haskell-build.yml` | +| Elixir (0.21+) | `secretspec` on Hex.pm, using `secretspec serve` | `elixir-package.yml` | + + + +The Elixir SDK is available from SecretSpec 0.21 and uses the shared resolver IPC protocol. ## Platform support @@ -91,6 +96,7 @@ the Ruby gem, and the PHP extension binaries is added in SecretSpec 0.17. | PHP | ✓ | ✓ | — | ✓ | ✓ (0.17+) | — | | Haskell (source) | ✓ (CI-covered) | — | — | — | ✓ (CI-covered, 0.17+) | — | | JVM (0.20+) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Elixir (0.21+) | ✓ | ✓ | — | ✓ | ✓ | — | Notes: diff --git a/docs/src/content/docs/sdk/elixir.mdx b/docs/src/content/docs/sdk/elixir.mdx new file mode 100644 index 000000000..574989361 --- /dev/null +++ b/docs/src/content/docs/sdk/elixir.mdx @@ -0,0 +1,66 @@ +--- +title: Elixir SDK +description: Resolve SecretSpec secrets from Elixir +--- + +import VersionCompatibility from '@cachix/site-kit/ui/VersionCompatibility.astro'; + + + +The Elixir SDK (`secretspec`, 0.21+) is a pure Elixir client for the `secretspec.resolver/1` IPC protocol. It requires a compatible `secretspec` executable (0.21+) and launches `secretspec serve` directly, without a shell, NIF, native archive, or Rust code in the Hex package. + +## Install + +```elixir +defp deps do + [{:secretspec, "~> 0.21"}] +end +``` + +Install SecretSpec separately and make `secretspec` available on `PATH`, or pass an explicit absolute `executable:` option. + +## Sessions + +A session fixes the manifest, profile, scope, provider override, and reason for its lifetime. Resolution addresses one exact declared name at a time. + +```elixir +{:ok, session} = + SecretSpec.Session.start_link( + manifest: "/project/secretspec.toml", + profile: "production", + scope: "deploy", + reason: "start application" + ) + +result = SecretSpec.Session.get(session, "DATABASE_URL", + representation: :value, + purpose: %{ + consumer: "my_app", + operation: "connect", + host: "db.example.com" + } +) + +SecretSpec.Session.close(session) +``` + +`representation` is `:auto`, `:value`, or `:path`. Path results are resolver-owned leases. Release them explicitly with `SecretSpec.Session.release/2`, or close the session to release all outstanding leases. + +`purpose` must include non-empty `consumer` and `operation` fields. Optional `host` and `path` fields provide additional attribution. + +## Results + +- `{:ok, %SecretSpec.Secret{}}` +- `{:missing, required}` +- `:undeclared` +- `{:error, %SecretSpec.Error{}}` + +Use `SecretSpec.with_session/2` for scoped lifetimes: + +```elixir +SecretSpec.with_session([manifest: "/project/secretspec.toml"], fn session -> + SecretSpec.Session.get(session, "DATABASE_URL", purpose: %{consumer: "app", operation: "connect"}) +end) +``` + +Whole-profile resolution, reports, `SecretSpec.Builder`, and `abi_version/0` are not part of the resolver protocol. diff --git a/docs/src/content/docs/sdk/overview.mdx b/docs/src/content/docs/sdk/overview.mdx index 5eeb88dc1..28cc5a570 100644 --- a/docs/src/content/docs/sdk/overview.mdx +++ b/docs/src/content/docs/sdk/overview.mdx @@ -6,7 +6,7 @@ description: How the SecretSpec language SDKs work import VersionCompatibility from '@cachix/site-kit/ui/VersionCompatibility.astro'; SecretSpec ships SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, -PHP, C# (0.16+), Swift (0.18+) and JVM languages (0.20+). They all resolve +PHP, C# (0.16+), Swift (0.18+), JVM languages (0.20+), and Elixir (0.21+). They all resolve secrets from the same declarative `secretspec.toml`, and they all behave identically, because they share one resolver. @@ -43,6 +43,7 @@ that core rather than a reimplementation: and falls back to loading the same C ABI at runtime through `ext-ffi`. - **JVM (0.20+)** loads the same C ABI with JNA from a runtime-specific native asset in the Jar package. +- **Elixir (0.21+)** uses a pure Elixir client for `secretspec.resolver/1` and launches `secretspec serve` directly. Because resolution happens in one place, every provider, chain, profile, and generator works the same in every language, and a new provider added to the core @@ -80,8 +81,8 @@ print(resolved.secrets["DATABASE_URL"].get) See each language's page for the idiomatic spelling: [Rust](/sdk/rust), [Python](/sdk/python), [Go](/sdk/go), [Ruby](/sdk/ruby), [Node.js](/sdk/nodejs), [Haskell](/sdk/haskell), [PHP](/sdk/php), -[C# (0.16+)](/sdk/csharp), [Swift (0.18+)](/sdk/swift) -and [JVM languages (0.20+)](/sdk/jvm). +[C# (0.16+)](/sdk/csharp), [Swift (0.18+)](/sdk/swift), +[JVM languages (0.20+)](/sdk/jvm), and [Elixir (0.21+)](/sdk/elixir). Every builder also takes a [scope (0.17+)](/concepts/scopes/), resolving only a named subset of the profile and returning the selected name on @@ -132,6 +133,7 @@ no runtime library path to set: - **JVM** ships the `cdylib` as runtime-specific native assets in one Jar package and loads the matching asset through JNA; glibc/musl Linux, Intel/Arm macOS, and x64/Arm64 Windows assets are included. +- **Elixir (0.21+)** is architecture-independent and requires a compatible `secretspec` executable. Because the resolver is linked or embedded directly, the SDKs do not depend on a separately installed `cdylib` or an `LD_LIBRARY_PATH`/`SECRETSPEC_FFI_LIB` @@ -156,6 +158,7 @@ SecretSpec 0.17. | PHP | ✓ | ✓ | — | ✓ | ✓ (0.17+) | — | | Haskell (source) | ✓ | — | — | — | ✓ (0.17+) | — | | JVM (0.20+) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Elixir (0.21+) | ✓ | ✓ | — | ✓ | ✓ | — | Most Linux packages build against a manylinux_2_28 baseline (glibc 2.28 or newer); the C# and JVM packages additionally ship musl Linux assets. Hackage diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 1fbf0271f..7e82bcf6e 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -117,6 +117,8 @@ const consumerMetadata: ConsumerMetadata[] = [ secrets: ['DATABASE_URL', 'API_KEY', 'SENTRY_DSN'] }, { icon: 'openjdk', label: 'JVM', href: '/sdk/jvm/', secrets: ['DATABASE_URL', 'API_KEY', 'SENTRY_DSN'] }, + { icon: 'elixir', label: 'Elixir (0.21+)', href: '/sdk/elixir/', + secrets: ['DATABASE_URL', 'SECRET_KEY_BASE', 'API_KEY'] }, { icon: 'githubactions', label: 'GitHub Actions', href: '/ci/github-actions/', secrets: ['NPM_TOKEN', 'AWS_SECRET_ACCESS_KEY', 'CODECOV_TOKEN'] }, { label: 'Forgejo Actions', href: '/ci/github-actions/', @@ -185,6 +187,7 @@ const consumerColors: Record = { 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(<>, true), do: reject_cr(rest, true) + defp reject_cr(<>, _in_string), do: {:error, :carriage_return} + defp reject_cr(<>, true), do: reject_cr(rest, false) + defp reject_cr(<>, 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(<> = 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(<>), do: take_string_chars(rest, <>) + defp take_string(_), do: {:error, :invalid_json} + + defp take_string_chars(<>, acc), do: {:ok, <>, rest} + + defp take_string_chars(<>, 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