From 5c14d6830578fc196628f39126b446202c1b9efe Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 16 Sep 2026 10:53:28 +0200 Subject: [PATCH 01/20] Add Elixir SDK with precompiled NIFs Introduce secretspec-ex, a Rustler binding that calls the shared Rust resolver from Elixir. Add a release workflow that attaches precompiled NIF archives to GitHub Releases and publishes the package to Hex.pm, and include the SDK in workspace and devenv setup. --- .github/workflows/elixir-package.yml | 187 ++++++++++++++ .github/workflows/pre-release.yml | 11 + .github/workflows/sdks.yml | 2 +- CHANGELOG.md | 6 +- Cargo.lock | 48 +++- Cargo.toml | 1 + conformance/README.md | 8 +- conformance/run.sh | 5 + devenv.nix | 6 + docs/astro.config.ts | 5 + docs/src/content/docs/development/sdks.mdx | 9 +- docs/src/content/docs/sdk/elixir.mdx | 70 ++++++ docs/src/content/docs/sdk/overview.mdx | 11 +- scripts/ci-sdks.sh | 6 + scripts/sync-sdk-versions.sh | 15 ++ secretspec-ex/.formatter.exs | 3 + secretspec-ex/.gitignore | 28 +++ secretspec-ex/README.md | 21 ++ secretspec-ex/config/config.exs | 5 + secretspec-ex/lib/secret_spec.ex | 110 +++++++++ secretspec-ex/lib/secret_spec/builder.ex | 81 +++++++ .../lib/secret_spec/caller_context.ex | 13 + secretspec-ex/lib/secret_spec/error.ex | 8 + .../lib/secret_spec/missing_required_error.ex | 9 + secretspec-ex/lib/secret_spec/native.ex | 22 ++ secretspec-ex/lib/secret_spec/report.ex | 5 + secretspec-ex/lib/secret_spec/resolved.ex | 32 +++ .../lib/secret_spec/resolved_secret.ex | 9 + .../lib/secret_spec/secret_report.ex | 5 + secretspec-ex/mix.exs | 42 ++++ secretspec-ex/mix.lock | 11 + .../native/secretspec_native/Cargo.toml | 11 + .../native/secretspec_native/src/lib.rs | 16 ++ secretspec-ex/test/conformance_test.exs | 36 +++ secretspec-ex/test/secret_spec_test.exs | 229 ++++++++++++++++++ secretspec-ex/test/test_helper.exs | 1 + 36 files changed, 1076 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/elixir-package.yml create mode 100644 docs/src/content/docs/sdk/elixir.mdx create mode 100644 secretspec-ex/.formatter.exs create mode 100644 secretspec-ex/.gitignore create mode 100644 secretspec-ex/README.md create mode 100644 secretspec-ex/config/config.exs create mode 100644 secretspec-ex/lib/secret_spec.ex create mode 100644 secretspec-ex/lib/secret_spec/builder.ex create mode 100644 secretspec-ex/lib/secret_spec/caller_context.ex create mode 100644 secretspec-ex/lib/secret_spec/error.ex create mode 100644 secretspec-ex/lib/secret_spec/missing_required_error.ex create mode 100644 secretspec-ex/lib/secret_spec/native.ex create mode 100644 secretspec-ex/lib/secret_spec/report.ex create mode 100644 secretspec-ex/lib/secret_spec/resolved.ex create mode 100644 secretspec-ex/lib/secret_spec/resolved_secret.ex create mode 100644 secretspec-ex/lib/secret_spec/secret_report.ex create mode 100644 secretspec-ex/mix.exs create mode 100644 secretspec-ex/mix.lock create mode 100644 secretspec-ex/native/secretspec_native/Cargo.toml create mode 100644 secretspec-ex/native/secretspec_native/src/lib.rs create mode 100644 secretspec-ex/test/conformance_test.exs create mode 100644 secretspec-ex/test/secret_spec_test.exs create mode 100644 secretspec-ex/test/test_helper.exs diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml new file mode 100644 index 00000000..aeba579d --- /dev/null +++ b/.github/workflows/elixir-package.yml @@ -0,0 +1,187 @@ +name: "Elixir SDK" + +# Builds the Rustler NIF for the Elixir SDK on each supported target, publishes +# the NIF archives to the GitHub Release, and publishes the Hex package with the +# generated Rustler checksum file. + +on: + workflow_call: + inputs: + release_tag: + description: Existing GitHub Release tag to upload artifacts to + required: false + type: string + default: "" + workflow_dispatch: + inputs: + release_tag: + description: Existing GitHub Release tag to upload artifacts to + required: false + type: string + default: "" + push: + tags: + - v** + pull_request: + paths: + - "secretspec-ex/**" + - ".github/workflows/elixir-package.yml" + - "scripts/install-rustup.sh" + - "scripts/sync-sdk-versions.sh" + +permissions: + contents: read + +jobs: + test: + name: Elixir tests + 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 Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + - name: Fetch dependencies + working-directory: secretspec-ex + run: mix deps.get + - name: Run tests + working-directory: secretspec-ex + env: + SECRETSPEC_EX_BUILD: "1" + run: mix test + + nif: + name: NIF ${{ matrix.target }} + needs: [test] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } + - { target: aarch64-unknown-linux-gnu, runner: ubuntu-24.04-arm } + - { target: aarch64-apple-darwin, runner: macos-latest } + - { target: x86_64-pc-windows-msvc, runner: windows-latest } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive + - name: Install verified rustup for cross builds + if: matrix.use-cross + shell: bash + run: | + bash scripts/install-rustup.sh + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Install Rust (pinned by rust-toolchain.toml) + uses: dtolnay/rust-toolchain@stable + with: + target: ${{ matrix.target }} + - name: Sync SDK package versions + run: bash scripts/sync-sdk-versions.sh + - name: Build Rustler NIF + id: build + uses: philss/rustler-precompiled-action@v1.1.5 + with: + project-name: secretspec_native + project-version: ${{ env.PROJECT_VERSION }} + target: ${{ matrix.target }} + nif-version: "2.15" + use-cross: ${{ matrix.use-cross || false }} + project-dir: secretspec-ex/native/secretspec_native + - name: Verify generated archive + shell: bash + run: test -s "${{ steps.build.outputs.file-path }}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: elixir-nif-${{ matrix.target }} + path: ${{ steps.build.outputs.file-path }} + + publish-nifs: + name: Publish Elixir NIFs + needs: nif + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || + inputs.release_tag != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: elixir-nif-* + path: artifacts + merge-multiple: true + - name: Attach NIF archives to the GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + run: | + set -euo pipefail + mapfile -t assets < <(find artifacts -type f -name 'libsecretspec_native-*.tar.gz' -print | sort) + if [[ "${#assets[@]}" -ne 4 ]]; then + printf 'expected 4 Elixir NIF archives, found %s\n' "${#assets[@]}" >&2 + printf '%s\n' "${assets[@]}" >&2 + exit 1 + fi + bash scripts/upload-release-asset.sh "$RELEASE_TAG" "${assets[@]}" + + package: + name: Build and publish Elixir package + needs: [test, publish-nifs] + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || + inputs.release_tag != '' + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: hex + url: https://hex.pm/packages/secretspec + 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 Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + - name: Sync SDK package versions + run: bash scripts/sync-sdk-versions.sh + - name: Fetch dependencies + working-directory: secretspec-ex + run: mix deps.get + - name: Generate Rustler checksums from release assets + working-directory: secretspec-ex + run: mix rustler_precompiled.download SecretSpec.Native --all --print + - name: Verify Rustler checksum file + working-directory: secretspec-ex + run: test -s checksum-Elixir.SecretSpec.Native.exs + - name: Build Hex package + working-directory: secretspec-ex + run: mix hex.build + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: elixir-package + path: secretspec-ex/secretspec-*.tar + - name: Publish package to Hex.pm + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + working-directory: secretspec-ex + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + run: mix hex.publish --yes diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index a29cda5d..174ff21e 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: + release_tag: "" + python: name: Python wheels needs: authorize diff --git a/.github/workflows/sdks.yml b/.github/workflows/sdks.yml index 40edf474..80da6a0f 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 b6518eb9..0f4bb1cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## Unreleased ### Added @@ -237,6 +237,10 @@ 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`) resolves SecretSpec declarations from + Elixir through a Rustler binding, with precompiled NIF archives for supported + Linux, macOS, and Windows targets. + ### Fixed - IPC rejects incompatible secret representations before contacting providers, diff --git a/Cargo.lock b/Cargo.lock index b2047550..2b6f61a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1428,7 +1428,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -4095,6 +4095,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -4351,7 +4361,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -5718,6 +5728,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustler" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ac8495cb6091c1f8bd0c3bb816cd4e6f0a073f396d77eb602cb7f0615212de" +dependencies = [ + "inventory", + "libc", + "libloading 0.9.0", + "regex-lite", + "rustler_codegen", +] + +[[package]] +name = "rustler_codegen" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780a2a6b7e3cfac35820473f85cec1cd84d1bdd37ebcb4e24fb126ef0f074838" +dependencies = [ + "heck", + "inventory", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rustls" version = "0.21.12" @@ -6127,6 +6163,14 @@ dependencies = [ "secretspec", ] +[[package]] +name = "secretspec_native" +version = "0.20.0" +dependencies = [ + "rustler", + "secretspec", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index 02df4534..b3535392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "secretspec-node", "secretspec-php", "secretspec-py", + "secretspec-ex/native/secretspec_native", ] resolver = "2" diff --git a/conformance/README.md b/conformance/README.md index 92eacada..69f705b9 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 f6faa7c3..938adc65 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -101,6 +101,10 @@ run_jvm() { ( cd secretspec-jvm ./gradlew test ); } +run_elixir() { ( + cd secretspec-ex + SECRETSPEC_EX_BUILD=1 mix test test/conformance_test.exs +); } run "Python" python run_python run "Go" go run_go @@ -117,6 +121,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 b93f28b4..06e4f15b 100644 --- a/devenv.nix +++ b/devenv.nix @@ -69,6 +69,10 @@ enable = true; jdk.package = pkgs.jdk21; }; + languages.elixir = { + enable = true; + package = pkgs.beam28Packages.elixir_1_19; + }; packages = [ # documentation link validation @@ -111,6 +115,8 @@ env = { # Gradle runs on JDK 21 but compiles the JVM SDK against this JDK 11 toolchain. SECRETSPEC_JVM_TARGET_JDK = "${pkgs.jdk11}"; + # Development builds compile the Elixir NIF locally instead of downloading an artifact. + SECRETSPEC_EX_BUILD = "1"; } // # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). # Keep these Linux-only: interpolating the cross-toolchain paths on macOS makes diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 1a7c4610..253cd7f0 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 7e007b0f..074bdfb1 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,12 @@ 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 + Rustler NIF release assets | `elixir-package.yml` | + + + +The Elixir SDK is scheduled for SecretSpec 0.21. Its package and workflow are +available for integration testing while that release is prepared. ## Platform support @@ -91,6 +97,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 00000000..58b04dce --- /dev/null +++ b/docs/src/content/docs/sdk/elixir.mdx @@ -0,0 +1,70 @@ +--- +title: Elixir SDK +description: Resolve SecretSpec secrets from Elixir +--- + +import VersionCompatibility from '@cachix/site-kit/ui/VersionCompatibility.astro'; + + + +The Elixir SDK (`secretspec`) is a thin [Rustler](https://hex.pm/packages/rustler) binding over the shared SecretSpec Rust resolver. Providers, profiles, fallback chains, generated values, and materialized paths remain in the Rust core. + +## Install {/* #install-021 */} + + + +Add the package to `mix.exs`: + +```elixir +defp deps do + [{:secretspec, "~> 0.21"}] +end +``` + +The package includes precompiled NIFs for Linux x64 and arm64, macOS Apple silicon, and Windows x64. It uses RustlerPrecompiled to select the matching native archive. + +## Quick start + +```elixir +resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_provider("dotenv://.env") + |> SecretSpec.Builder.with_reason("start application") + |> SecretSpec.Builder.load() + +url = resolved.secrets["DATABASE_URL"] |> SecretSpec.ResolvedSecret.get() +SecretSpec.Resolved.set_as_env(resolved) +SecretSpec.Resolved.close(resolved) +``` + +`SecretSpec.resolve/1` provides the same operation for one-shot calls: + +```elixir +resolved = SecretSpec.resolve(provider: "keyring://", reason: "boot") +``` + +A missing required secret raises `SecretSpec.MissingRequiredError`. Other resolver failures raise `SecretSpec.Error` with a stable `kind`. + +## Reports + +`report/1` performs a value-free preflight and returns status and provenance metadata: + +```elixir +report = SecretSpec.report(path: "secretspec.toml", provider: "dotenv://.env") +``` + +## Scopes + +Use `with_scope/2` to resolve a named `[scopes.]` subset. The selected name is available as `resolved.scope` and `report.scope`. + +## Inline specifications + +Use `with_inline_spec/3` when the declaration lives in application code. The base directory controls relative provider paths, while the Rust core validates the strict inline-spec v1 envelope. + +## Lifetime and file secrets + +Secrets with `as_path = true` are materialized as mode-0400 temporary files. Call `SecretSpec.Resolved.close/1` when the resolved value is no longer needed so those files are removed. + +## Native build + +The release package carries precompiled NIF archives. A local development build can compile the NIF from the workspace source with `SECRETSPEC_EX_BUILD=1 mix test`. diff --git a/docs/src/content/docs/sdk/overview.mdx b/docs/src/content/docs/sdk/overview.mdx index 5eeb88dc..439f515b 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,8 @@ 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 Rustler NIF and RustlerPrecompiled archives to call + the shared resolver directly from the BEAM. 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 +82,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 +134,8 @@ 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+)** ships RustlerPrecompiled NIF archives for Linux x64/Arm64, + macOS Apple silicon, and Windows x64. 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 +160,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/scripts/ci-sdks.sh b/scripts/ci-sdks.sh index 9604184e..60c33b6e 100755 --- a/scripts/ci-sdks.sh +++ b/scripts/ci-sdks.sh @@ -191,6 +191,11 @@ run_jvm() { ( cd secretspec-jvm && ./gradlew build ) } +run_elixir() { + echo "==> Elixir" + ( cd secretspec-ex && SECRETSPEC_EX_BUILD=1 mix test ) +} + run_php() { echo "==> PHP" # The PHP SDK has two native backends over the same resolver; exercise both. @@ -243,6 +248,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 8ef347dd..901da2ac 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 00000000..07c11e1d --- /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 00000000..45ed99af --- /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 00000000..57bce746 --- /dev/null +++ b/secretspec-ex/README.md @@ -0,0 +1,21 @@ +# SecretSpec Elixir SDK + +The Elixir SDK is a thin Rustler binding over the shared SecretSpec Rust +resolver. The Elixir layer builds the JSON request and exposes the response as +small structs; providers, profiles, fallback chains, and materialized paths +remain in Rust. + +```elixir +resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_provider("dotenv://.env") + |> SecretSpec.Builder.with_reason("start application") + |> SecretSpec.Builder.load() + +resolved.secrets["DATABASE_URL"] |> SecretSpec.ResolvedSecret.get() +SecretSpec.Resolved.set_as_env(resolved) +SecretSpec.Resolved.close(resolved) +``` + +Build and test with `mix test`. A Rust toolchain and a C compiler are required +for the native extension. diff --git a/secretspec-ex/config/config.exs b/secretspec-ex/config/config.exs new file mode 100644 index 00000000..f93fdeb4 --- /dev/null +++ b/secretspec-ex/config/config.exs @@ -0,0 +1,5 @@ +import Config + +config :secretspec, SecretSpec.Native, + crate: "secretspec_native", + mode: :release diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex new file mode 100644 index 00000000..7fb637c0 --- /dev/null +++ b/secretspec-ex/lib/secret_spec.ex @@ -0,0 +1,110 @@ +defmodule SecretSpec do + @moduledoc """ + Elixir SDK for SecretSpec. + + The Rustler native module calls the shared Rust resolver directly. This layer + builds requests and maps the JSON response into Elixir structs. + """ + + alias SecretSpec.{ + Builder, + Error, + MissingRequiredError, + Native, + Report, + Resolved, + ResolvedSecret, + SecretReport + } + + @doc "Starts a fluent SecretSpec builder." + def builder, do: Builder.new() + + @doc "Resolves secrets using the supplied options." + def resolve(opts \\ []) do + builder() + |> Builder.configure(opts) + |> Builder.load() + end + + @doc "Returns a value-free resolution report." + def report(opts \\ []) do + builder() + |> Builder.configure(opts) + |> Builder.report() + end + + @doc "Returns the native bridge version." + def abi_version, do: Native.abi_version() + + @doc false + def checked_response({request, versioned}, kind, expected_version) do + request_json = Jason.encode!(request) + raw = if versioned, do: Native.call(request_json), else: Native.resolve(request_json) + envelope = Jason.decode!(raw) + + case envelope do + %{"ok" => true, "response" => %{"schema_version" => ^expected_version} = response} -> + response + + %{"ok" => true, "response" => response} -> + raise %Error{ + kind: "version", + message: + "unsupported #{kind} schema version #{response["schema_version"]} (expected #{expected_version})" + } + + %{"ok" => false, "error" => %{"kind" => error_kind, "message" => message}} -> + raise %Error{kind: error_kind, message: message} + end + end + + @doc false + def to_resolved(%{"missing_required" => [_ | _] = missing}), + do: raise(%MissingRequiredError{missing: missing}) + + def to_resolved(response) do + secrets = + Map.new(response["secrets"] || %{}, fn {name, entry} -> + {name, + %ResolvedSecret{ + value: entry["value"], + path: entry["path"], + as_path: entry["as_path"], + source: entry["source"], + source_provider: entry["source_provider"] + }} + end) + + %Resolved{ + provider: response["provider"], + profile: response["profile"], + secrets: secrets, + missing_optional: response["missing_optional"] || [], + scope: response["scope"] + } + end + + @doc false + def to_report(response) do + secrets = + Enum.map(response["secrets"] || [], fn entry -> + %SecretReport{ + name: entry["name"], + status: entry["status"], + required: entry["required"], + source_provider: entry["source_provider"], + default_applied: entry["default_applied"], + generated: entry["generated"], + as_path: entry["as_path"] + } + end) + + %Report{ + provider: response["provider"], + profile: response["profile"], + secrets: secrets, + scope: response["scope"] + } + end +end diff --git a/secretspec-ex/lib/secret_spec/builder.ex b/secretspec-ex/lib/secret_spec/builder.ex new file mode 100644 index 00000000..0a1d617e --- /dev/null +++ b/secretspec-ex/lib/secret_spec/builder.ex @@ -0,0 +1,81 @@ +defmodule SecretSpec.Builder do + @moduledoc "Fluent request builder for SecretSpec resolution." + + alias SecretSpec.CallerContext + defstruct request: %{}, inline: nil + + @doc "Creates an empty builder." + def new, do: %__MODULE__{} + @doc "Sets the manifest path." + def with_path(%__MODULE__{} = builder, path), + do: %{builder | request: Map.put(builder.request, "path", path), inline: nil} + + @doc "Sets the provider URI or name." + def with_provider(%__MODULE__{} = builder, provider), do: put(builder, "provider", provider) + @doc "Sets the profile." + def with_profile(%__MODULE__{} = builder, profile), do: put(builder, "profile", profile) + @doc "Sets the manifest scope." + def with_scope(%__MODULE__{} = builder, scope), do: put(builder, "scope", scope) + @doc "Sets the audit reason." + def with_reason(%__MODULE__{} = builder, reason), do: put(builder, "reason", reason) + @doc "Sets caller audit context." + def with_caller(%__MODULE__{} = builder, caller), + do: put(builder, "caller", CallerContext.to_request(caller)) + + @doc "Controls whether values are omitted." + def with_no_values(%__MODULE__{} = builder, value \\ true), do: put(builder, "no_values", value) + @doc "Sets an inline specification and its logical base directory." + def with_inline_spec(%__MODULE__{} = builder, spec, base_dir), + do: %{builder | request: Map.delete(builder.request, "path"), inline: {spec, base_dir}} + + @doc "Applies one-shot options to a builder." + def configure(builder, opts) do + Enum.reduce(opts, builder, fn + {:path, value}, builder -> with_path(builder, value) + {:provider, value}, builder -> with_provider(builder, value) + {:profile, value}, builder -> with_profile(builder, value) + {:scope, value}, builder -> with_scope(builder, value) + {:reason, value}, builder -> with_reason(builder, value) + {:caller, value}, builder -> with_caller(builder, value) + {:no_values, value}, builder -> with_no_values(builder, value) + end) + end + + @doc "Resolves and returns a `SecretSpec.Resolved`." + def load(%__MODULE__{} = builder), + do: + builder + |> native_request() + |> SecretSpec.checked_response("resolve", 2) + |> SecretSpec.to_resolved() + + @doc "Resolves without returning secret values." + def report(%__MODULE__{} = builder), + do: + builder + |> native_request("report") + |> SecretSpec.checked_response("report", 1) + |> SecretSpec.to_report() + + defp put(builder, key, value), do: %{builder | request: Map.put(builder.request, key, value)} + defp native_request(%__MODULE__{request: request, inline: nil}), do: {request, false} + + defp native_request(%__MODULE__{request: request, inline: {spec, base_dir}}), + do: + {%{ + "request_version" => 1, + "operation" => "resolve", + "source" => %{ + "kind" => "inline", + "spec_version" => 1, + "base_dir" => base_dir, + "spec" => spec + }, + "options" => request + }, true} + + defp native_request(builder, mode) do + {request, versioned} = native_request(builder) + {Map.put(request, "mode", mode), versioned} + end +end diff --git a/secretspec-ex/lib/secret_spec/caller_context.ex b/secretspec-ex/lib/secret_spec/caller_context.ex new file mode 100644 index 00000000..6c85177a --- /dev/null +++ b/secretspec-ex/lib/secret_spec/caller_context.ex @@ -0,0 +1,13 @@ +defmodule SecretSpec.CallerContext do + @moduledoc "Caller information sent to SecretSpec's audit policy." + + defstruct [:name, :version, :operation, :resource] + + @doc "Converts the caller context to the native request shape." + def to_request(%__MODULE__{} = caller) do + caller + |> Map.from_struct() + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new(fn {key, value} -> {Atom.to_string(key), value} end) + end +end diff --git a/secretspec-ex/lib/secret_spec/error.ex b/secretspec-ex/lib/secret_spec/error.ex new file mode 100644 index 00000000..e24b674c --- /dev/null +++ b/secretspec-ex/lib/secret_spec/error.ex @@ -0,0 +1,8 @@ +defmodule SecretSpec.Error do + @moduledoc "A SecretSpec resolution or native bridge error." + + defexception [:kind, :message] + + @impl true + def message(%__MODULE__{kind: kind, message: message}), do: "#{message} (kind: #{kind})" +end diff --git a/secretspec-ex/lib/secret_spec/missing_required_error.ex b/secretspec-ex/lib/secret_spec/missing_required_error.ex new file mode 100644 index 00000000..5d2631a1 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/missing_required_error.ex @@ -0,0 +1,9 @@ +defmodule SecretSpec.MissingRequiredError do + @moduledoc "Raised when required secrets are missing." + + defexception [:missing] + + @impl true + def message(%__MODULE__{missing: missing}), + do: "missing required secret(s): #{Enum.join(missing, ", ")}" +end diff --git a/secretspec-ex/lib/secret_spec/native.ex b/secretspec-ex/lib/secret_spec/native.ex new file mode 100644 index 00000000..c6fb2f39 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/native.ex @@ -0,0 +1,22 @@ +defmodule SecretSpec.Native do + @moduledoc false + + version = Mix.Project.config()[:version] + + use RustlerPrecompiled, + otp_app: :secretspec, + crate: "secretspec_native", + targets: [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc" + ], + base_url: "https://github.com/cachix/secretspec/releases/download/v#{version}", + force_build: System.get_env("SECRETSPEC_EX_BUILD") in ["1", "true"], + version: version + + def resolve(_request), do: :erlang.nif_error(:nif_not_loaded) + def call(_request), do: :erlang.nif_error(:nif_not_loaded) + def abi_version, do: :erlang.nif_error(:nif_not_loaded) +end diff --git a/secretspec-ex/lib/secret_spec/report.ex b/secretspec-ex/lib/secret_spec/report.ex new file mode 100644 index 00000000..be3e44d7 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/report.ex @@ -0,0 +1,5 @@ +defmodule SecretSpec.Report do + @moduledoc "A value-free resolution snapshot." + + defstruct [:provider, :profile, :secrets, scope: nil] +end diff --git a/secretspec-ex/lib/secret_spec/resolved.ex b/secretspec-ex/lib/secret_spec/resolved.ex new file mode 100644 index 00000000..3812f9d1 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/resolved.ex @@ -0,0 +1,32 @@ +defmodule SecretSpec.Resolved do + @moduledoc "A successful SecretSpec resolution." + + alias SecretSpec.ResolvedSecret + defstruct [:provider, :profile, :secrets, missing_optional: [], scope: nil] + + @doc "Exports usable secrets into the Erlang VM environment." + def set_as_env(%__MODULE__{secrets: secrets}) do + Enum.each(secrets, fn {name, secret} -> + case ResolvedSecret.get(secret) do + nil -> :ok + value -> System.put_env(name, value) + end + end) + end + + @doc "Returns a flat map of secret names to usable values." + def fields(%__MODULE__{secrets: secrets}), + do: Map.new(secrets, fn {name, secret} -> {name, ResolvedSecret.get(secret)} end) + + @doc "Removes materialized files belonging to this resolution." + def close(%__MODULE__{secrets: secrets}) do + secrets + |> Map.values() + |> Enum.filter(&(&1.as_path && is_binary(&1.path))) + |> Enum.each(fn secret -> File.rm(secret.path) |> ignore_missing_file() end) + end + + defp ignore_missing_file(:ok), do: :ok + defp ignore_missing_file({:error, :enoent}), do: :ok + defp ignore_missing_file({:error, reason}), do: raise(File.Error, reason: reason) +end diff --git a/secretspec-ex/lib/secret_spec/resolved_secret.ex b/secretspec-ex/lib/secret_spec/resolved_secret.ex new file mode 100644 index 00000000..7c8511ad --- /dev/null +++ b/secretspec-ex/lib/secret_spec/resolved_secret.ex @@ -0,0 +1,9 @@ +defmodule SecretSpec.ResolvedSecret do + @moduledoc "One resolved secret and its provenance." + + defstruct [:value, :path, :as_path, :source, :source_provider] + + @doc "Returns the usable value or materialized file path." + def get(%__MODULE__{as_path: true, path: path}), do: path + def get(%__MODULE__{value: value}), do: value +end diff --git a/secretspec-ex/lib/secret_spec/secret_report.ex b/secretspec-ex/lib/secret_spec/secret_report.ex new file mode 100644 index 00000000..da5d5196 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/secret_report.ex @@ -0,0 +1,5 @@ +defmodule SecretSpec.SecretReport do + @moduledoc "Value-free outcome for one declared secret." + + defstruct [:name, :status, :required, :source_provider, :default_applied, :generated, :as_path] +end diff --git a/secretspec-ex/mix.exs b/secretspec-ex/mix.exs new file mode 100644 index 00000000..224f9174 --- /dev/null +++ b/secretspec-ex/mix.exs @@ -0,0 +1,42 @@ +defmodule SecretSpec.MixProject do + use Mix.Project + + @source_url "https://github.com/cachix/secretspec" + + def project do + [ + app: :secretspec, + version: "0.19.1", + 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 + [extra_applications: [:logger]] + end + + defp package do + [ + files: ["lib", "native", "checksum-*.exs", "mix.exs", "README.md"], + licenses: ["MIT"], + links: %{"GitHub" => @source_url} + ] + end + + defp deps do + [ + {:jason, "~> 1.4"}, + {:rustler_precompiled, "~> 0.9.0"}, + {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}, + {:rustler, ">= 0.0.0", optional: true} + ] + end +end diff --git a/secretspec-ex/mix.lock b/secretspec-ex/mix.lock new file mode 100644 index 00000000..2873fc4b --- /dev/null +++ b/secretspec-ex/mix.lock @@ -0,0 +1,11 @@ +%{ + "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"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "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"}, + "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, +} diff --git a/secretspec-ex/native/secretspec_native/Cargo.toml b/secretspec-ex/native/secretspec_native/Cargo.toml new file mode 100644 index 00000000..954981be --- /dev/null +++ b/secretspec-ex/native/secretspec_native/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "secretspec_native" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +rustler = "0.38" +secretspec = { path = "../../../secretspec" } diff --git a/secretspec-ex/native/secretspec_native/src/lib.rs b/secretspec-ex/native/secretspec_native/src/lib.rs new file mode 100644 index 00000000..e002ee2d --- /dev/null +++ b/secretspec-ex/native/secretspec_native/src/lib.rs @@ -0,0 +1,16 @@ +#[rustler::nif(schedule = "DirtyIo")] +fn resolve(request_json: String) -> String { + secretspec::resolve_json(&request_json) +} + +#[rustler::nif(schedule = "DirtyIo")] +fn call(request_json: String) -> String { + secretspec::call_json(&request_json) +} + +#[rustler::nif] +fn abi_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +rustler::init!("Elixir.SecretSpec.Native"); diff --git a/secretspec-ex/test/conformance_test.exs b/secretspec-ex/test/conformance_test.exs new file mode 100644 index 00000000..869ffde6 --- /dev/null +++ b/secretspec-ex/test/conformance_test.exs @@ -0,0 +1,36 @@ +defmodule SecretSpec.ConformanceTest do + use ExUnit.Case, async: true + + @fixtures Path.expand("../../conformance/fixtures", __DIR__) + + test "matches the canonical basic fixture" do + fixture = Path.join(@fixtures, "basic") + expected = fixture |> Path.join("expected.json") |> File.read!() |> Jason.decode!() + manifest = Path.join(fixture, "secretspec.toml") + provider = "dotenv://#{Path.join(fixture, ".env")}" + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.with_reason("conformance") + |> SecretSpec.Builder.load() + + assert canonical(resolved) == expected + end + + defp canonical(resolved) do + secrets = + Map.new(resolved.secrets, fn {name, secret} -> + value = if secret.as_path, do: File.read!(secret.path), else: secret.value + {name, %{"value" => value, "source" => secret.source, "as_path" => secret.as_path}} + end) + + %{ + "profile" => resolved.profile, + "secrets" => secrets, + "missing_required" => [], + "missing_optional" => Enum.sort(resolved.missing_optional) + } + end +end diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs new file mode 100644 index 00000000..317412c9 --- /dev/null +++ b/secretspec-ex/test/secret_spec_test.exs @@ -0,0 +1,229 @@ +defmodule SecretSpecTest do + use ExUnit.Case, async: true + + test "builder stores request options" do + builder = + SecretSpec.builder() + |> SecretSpec.Builder.with_path("secretspec.toml") + |> SecretSpec.Builder.with_provider("dotenv://.env") + |> SecretSpec.Builder.with_profile("development") + |> SecretSpec.Builder.with_reason("test") + + assert builder.request == %{ + "path" => "secretspec.toml", + "provider" => "dotenv://.env", + "profile" => "development", + "reason" => "test" + } + end + + test "caller context omits unset fields" do + caller = %SecretSpec.CallerContext{name: "test", operation: "resolve"} + + assert SecretSpec.CallerContext.to_request(caller) == %{ + "name" => "test", + "operation" => "resolve" + } + end + + test "abi version is non-empty" do + assert SecretSpec.Native.abi_version() != "" + end + + test "load returns values and provenance" do + {manifest, provider} = project("DATABASE_URL=postgres://db\n") + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.with_reason("elixir test") + |> SecretSpec.Builder.load() + + assert resolved.profile == "default" + assert resolved.secrets["DATABASE_URL"].value == "postgres://db" + assert resolved.secrets["DATABASE_URL"].source == "provider" + assert resolved.secrets["DATABASE_URL"].source_provider != nil + assert resolved.secrets["DEV_SESSION_SECRET"].value == "development-only-secret" + assert resolved.secrets["DEV_SESSION_SECRET"].source == "default" + assert resolved.missing_optional == ["SENTRY_DSN"] + refute Map.has_key?(resolved.secrets, "SENTRY_DSN") + end + + test "inline spec resolves at its logical base directory" do + {manifest, _provider} = project("") + base_dir = Path.dirname(manifest) + File.write!(Path.join(base_dir, "inline.env"), "TOKEN=inline-elixir\n") + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_inline_spec( + %{ + "project" => %{"name" => "elixir-inline"}, + "providers" => %{"env" => "dotenv://inline.env"}, + "profiles" => %{ + "default" => %{ + "secrets" => %{ + "TOKEN" => %{"description" => "token", "providers" => ["env"]} + } + } + } + }, + base_dir + ) + |> SecretSpec.Builder.with_reason("inline test") + |> SecretSpec.Builder.load() + + assert resolved.secrets["TOKEN"].value == "inline-elixir" + end + + test "scope is selected and returned" do + {manifest, provider} = project("DATABASE_URL=postgres://db\nSENTRY_DSN=https://sentry\n") + + builder = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.with_scope("database") + |> SecretSpec.Builder.with_reason("scoped test") + + resolved = SecretSpec.Builder.load(builder) + report = SecretSpec.Builder.report(builder) + + assert resolved.scope == "database" + assert Map.keys(resolved.secrets) == ["DATABASE_URL"] + assert report.scope == "database" + assert Enum.map(report.secrets, & &1.name) == ["DATABASE_URL"] + end + + test "report preserves status metadata" do + {manifest, provider} = project("DATABASE_URL=postgres://db\n") + + report = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.report() + + assert report.profile == "default" + + database = Enum.find(report.secrets, &(&1.name == "DATABASE_URL")) + assert database.name == "DATABASE_URL" + assert database.status == "resolved" + assert database.required + assert database.source_provider =~ "dotenv:" + refute database.default_applied + refute database.generated + refute database.as_path + + assert Enum.find(report.secrets, &(&1.name == "DEV_SESSION_SECRET")).default_applied + assert Enum.find(report.secrets, &(&1.name == "SENTRY_DSN")).status == "missing_optional" + end + + test "no_values returns nil fields" do + {manifest, provider} = project("DATABASE_URL=postgres://db\n") + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.with_no_values() + |> SecretSpec.Builder.load() + + assert SecretSpec.Resolved.fields(resolved) == %{ + "DATABASE_URL" => nil, + "DEV_SESSION_SECRET" => nil + } + end + + test "missing required raises MissingRequiredError" do + {manifest, provider} = project("") + + assert_raise SecretSpec.MissingRequiredError, ~r/DATABASE_URL/, fn -> + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.load() + end + end + + test "as_path returns a readable file and close removes it" do + manifest = """ + [project] + name = "elixir-test" + revision = "1.0" + + [profiles.default] + TLS_CERT = { description = "cert", required = true, as_path = true } + """ + + {manifest_path, provider} = project("TLS_CERT=----cert----\n", manifest) + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest_path) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.load() + + secret = resolved.secrets["TLS_CERT"] + assert secret.as_path + assert secret.value == nil + assert File.read!(secret.path) == "----cert----" + + SecretSpec.Resolved.close(resolved) + refute File.exists?(secret.path) + end + + test "invalid manifest raises SecretSpec.Error" do + assert_raise SecretSpec.Error, fn -> + SecretSpec.builder() + |> SecretSpec.Builder.with_path("/definitely/does/not/exist/secretspec.toml") + |> SecretSpec.Builder.load() + end + end + + test "set_as_env exports resolved secrets" do + {manifest, provider} = project("DATABASE_URL=postgres://db\n") + System.delete_env("DATABASE_URL") + + resolved = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.load() + + SecretSpec.Resolved.set_as_env(resolved) + assert System.get_env("DATABASE_URL") == "postgres://db" + System.delete_env("DATABASE_URL") + end + + defp project(dotenv, manifest \\ manifest()) do + directory = + Path.join(System.tmp_dir!(), "secretspec-ex-#{System.unique_integer([:positive])}") + + File.mkdir_p!(directory) + on_exit(fn -> File.rm_rf(directory) end) + + manifest_path = Path.join(directory, "secretspec.toml") + env_path = Path.join(directory, ".env") + File.write!(manifest_path, manifest) + File.write!(env_path, dotenv) + {manifest_path, "dotenv://#{env_path}"} + end + + defp manifest do + """ + [project] + name = "elixir-test" + revision = "1.0" + + [profiles.default] + DATABASE_URL = { description = "DB", required = true } + DEV_SESSION_SECRET = { description = "Development-only session secret", required = false, default = "development-only-secret" } + SENTRY_DSN = { description = "sentry", required = false } + + [scopes.database] + secrets = ["DATABASE_URL"] + """ + end +end diff --git a/secretspec-ex/test/test_helper.exs b/secretspec-ex/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/secretspec-ex/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From 59e78e27b8b9d50e22d28cca48c071938db661eb Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 2 Sep 2026 10:50:57 +0200 Subject: [PATCH 02/20] Address PR #416 review findings for the Elixir SDK - Build Linux NIFs in pinned manylinux_2_28 containers and verify portability - Align CARGO_TARGET_DIR with rustler-precompiled-action expectations - Include the Windows archive in release asset discovery - Add a pre_release input and release-PR guards to the Elixir workflow - Bootstrap Mix deps in ci-sdks.sh and conformance/run.sh - Keep the Hex package precompiled-only: drop the SECRETSPEC_EX_BUILD source-build path and ship no native/ sources to consumers - Use workspace inheritance in the native crate manifest, matching the other native SDK crates - Continue cleanup after a file-removal error in Resolved.close/1 --- .github/workflows/elixir-package.yml | 85 ++++++++++++++++--- .github/workflows/pre-release.yml | 1 + conformance/run.sh | 5 +- devenv.nix | 2 - docs/src/content/docs/sdk/elixir.mdx | 4 +- scripts/ci-sdks.sh | 8 +- secretspec-ex/README.md | 4 +- secretspec-ex/lib/secret_spec/native.ex | 5 +- secretspec-ex/lib/secret_spec/resolved.ex | 25 ++++-- secretspec-ex/mix.exs | 6 +- .../native/secretspec_native/Cargo.toml | 2 +- secretspec-ex/test/secret_spec_test.exs | 18 ++++ 12 files changed, 132 insertions(+), 33 deletions(-) diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml index aeba579d..a69046e0 100644 --- a/.github/workflows/elixir-package.yml +++ b/.github/workflows/elixir-package.yml @@ -7,6 +7,11 @@ name: "Elixir SDK" on: workflow_call: inputs: + pre_release: + description: Run as part of the canonical pre-release suite + required: false + type: boolean + default: false release_tag: description: Existing GitHub Release tag to upload artifacts to required: false @@ -35,6 +40,10 @@ permissions: jobs: test: name: Elixir tests + 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 @@ -53,33 +62,84 @@ jobs: run: mix deps.get - name: Run tests working-directory: secretspec-ex - env: - SECRETSPEC_EX_BUILD: "1" run: mix test - nif: + nif-linux: name: NIF ${{ matrix.target }} + if: >- + inputs.pre_release || + github.event_name != 'pull_request' || + !startsWith(github.event.pull_request.title, 'Release ') needs: [test] runs-on: ${{ matrix.runner }} + container: ${{ matrix.container }} strategy: fail-fast: false matrix: include: - - { target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } - - { target: aarch64-unknown-linux-gnu, runner: ubuntu-24.04-arm } - - { target: aarch64-apple-darwin, runner: macos-latest } - - { target: x86_64-pc-windows-msvc, runner: windows-latest } + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + container: quay.io/pypa/manylinux_2_28_x86_64:2026.08.05-1@sha256:e0b40ace8e818e96026eb47714b01998cbca022a6995797d0905474ce3e82ae8 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.05-1@sha256:f766b402889e40f439e7a3ee5788eef1aa3ef399d0110107d27419fa2ba9d905 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false submodules: recursive - - name: Install verified rustup for cross builds - if: matrix.use-cross + - name: Install verified rustup shell: bash run: | bash scripts/install-rustup.sh echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Install Rust (pinned by rust-toolchain.toml) + run: rustup toolchain install + - name: Sync SDK package versions + run: bash scripts/sync-sdk-versions.sh + - name: Build Rustler NIF + id: build + uses: philss/rustler-precompiled-action@v1.1.5 + env: + CARGO_TARGET_DIR: target + with: + project-name: secretspec_native + project-version: ${{ env.PROJECT_VERSION }} + target: ${{ matrix.target }} + nif-version: "2.15" + project-dir: secretspec-ex/native/secretspec_native + - name: Verify Linux portability + shell: bash + run: >- + bash scripts/check-linux-portability.sh + secretspec-ex/native/secretspec_native/target/${{ matrix.target }}/release/libsecretspec_native.so + - name: Verify generated archive + shell: bash + run: test -s "${{ steps.build.outputs.file-path }}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: elixir-nif-${{ matrix.target }} + path: ${{ steps.build.outputs.file-path }} + + nif-native: + name: NIF ${{ matrix.target }} + if: >- + inputs.pre_release || + github.event_name != 'pull_request' || + !startsWith(github.event.pull_request.title, 'Release ') + needs: [test] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { target: aarch64-apple-darwin, runner: macos-latest } + - { target: x86_64-pc-windows-msvc, runner: windows-latest } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive - name: Install Rust (pinned by rust-toolchain.toml) uses: dtolnay/rust-toolchain@stable with: @@ -89,12 +149,13 @@ jobs: - name: Build Rustler NIF id: build uses: philss/rustler-precompiled-action@v1.1.5 + env: + CARGO_TARGET_DIR: target with: project-name: secretspec_native project-version: ${{ env.PROJECT_VERSION }} target: ${{ matrix.target }} nif-version: "2.15" - use-cross: ${{ matrix.use-cross || false }} project-dir: secretspec-ex/native/secretspec_native - name: Verify generated archive shell: bash @@ -106,7 +167,7 @@ jobs: publish-nifs: name: Publish Elixir NIFs - needs: nif + needs: [nif-linux, nif-native] if: >- (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || inputs.release_tag != '' @@ -129,7 +190,7 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} run: | set -euo pipefail - mapfile -t assets < <(find artifacts -type f -name 'libsecretspec_native-*.tar.gz' -print | sort) + mapfile -t assets < <(find artifacts -type f -name '*secretspec_native-*.tar.gz' -print | sort) if [[ "${#assets[@]}" -ne 4 ]]; then printf 'expected 4 Elixir NIF archives, found %s\n' "${#assets[@]}" >&2 printf '%s\n' "${assets[@]}" >&2 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 174ff21e..0e467ace 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -92,6 +92,7 @@ jobs: contents: read uses: ./.github/workflows/elixir-package.yml with: + pre_release: true release_tag: "" python: diff --git a/conformance/run.sh b/conformance/run.sh index 938adc65..092c7566 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -103,7 +103,10 @@ run_jvm() { ( ); } run_elixir() { ( cd secretspec-ex - SECRETSPEC_EX_BUILD=1 mix test test/conformance_test.exs + mix local.hex --force + mix local.rebar --force + mix deps.get + mix test test/conformance_test.exs ); } run "Python" python run_python diff --git a/devenv.nix b/devenv.nix index 06e4f15b..c1e92215 100644 --- a/devenv.nix +++ b/devenv.nix @@ -115,8 +115,6 @@ env = { # Gradle runs on JDK 21 but compiles the JVM SDK against this JDK 11 toolchain. SECRETSPEC_JVM_TARGET_JDK = "${pkgs.jdk11}"; - # Development builds compile the Elixir NIF locally instead of downloading an artifact. - SECRETSPEC_EX_BUILD = "1"; } // # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). # Keep these Linux-only: interpolating the cross-toolchain paths on macOS makes diff --git a/docs/src/content/docs/sdk/elixir.mdx b/docs/src/content/docs/sdk/elixir.mdx index 58b04dce..69fba711 100644 --- a/docs/src/content/docs/sdk/elixir.mdx +++ b/docs/src/content/docs/sdk/elixir.mdx @@ -65,6 +65,6 @@ Use `with_inline_spec/3` when the declaration lives in application code. The bas Secrets with `as_path = true` are materialized as mode-0400 temporary files. Call `SecretSpec.Resolved.close/1` when the resolved value is no longer needed so those files are removed. -## Native build +## Native extension -The release package carries precompiled NIF archives. A local development build can compile the NIF from the workspace source with `SECRETSPEC_EX_BUILD=1 mix test`. +The release package carries precompiled NIF archives for its supported targets. diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh index 60c33b6e..fd0f72d5 100755 --- a/scripts/ci-sdks.sh +++ b/scripts/ci-sdks.sh @@ -193,7 +193,13 @@ run_jvm() { run_elixir() { echo "==> Elixir" - ( cd secretspec-ex && SECRETSPEC_EX_BUILD=1 mix test ) + ( + cd secretspec-ex + mix local.hex --force + mix local.rebar --force + mix deps.get + mix test + ) } run_php() { diff --git a/secretspec-ex/README.md b/secretspec-ex/README.md index 57bce746..a064ecdc 100644 --- a/secretspec-ex/README.md +++ b/secretspec-ex/README.md @@ -17,5 +17,5 @@ SecretSpec.Resolved.set_as_env(resolved) SecretSpec.Resolved.close(resolved) ``` -Build and test with `mix test`. A Rust toolchain and a C compiler are required -for the native extension. +Build and test a checkout with `mix test`. A Rust toolchain and a C compiler +are required. diff --git a/secretspec-ex/lib/secret_spec/native.ex b/secretspec-ex/lib/secret_spec/native.ex index c6fb2f39..378a6fb8 100644 --- a/secretspec-ex/lib/secret_spec/native.ex +++ b/secretspec-ex/lib/secret_spec/native.ex @@ -3,6 +3,9 @@ defmodule SecretSpec.Native do version = Mix.Project.config()[:version] + # local_checkout? = + # File.exists?(Path.expand("../../native/secretspec_native/Cargo.toml", __DIR__)) + use RustlerPrecompiled, otp_app: :secretspec, crate: "secretspec_native", @@ -13,7 +16,7 @@ defmodule SecretSpec.Native do "x86_64-pc-windows-msvc" ], base_url: "https://github.com/cachix/secretspec/releases/download/v#{version}", - force_build: System.get_env("SECRETSPEC_EX_BUILD") in ["1", "true"], + # force_build: local_checkout?, version: version def resolve(_request), do: :erlang.nif_error(:nif_not_loaded) diff --git a/secretspec-ex/lib/secret_spec/resolved.ex b/secretspec-ex/lib/secret_spec/resolved.ex index 3812f9d1..419bd9b4 100644 --- a/secretspec-ex/lib/secret_spec/resolved.ex +++ b/secretspec-ex/lib/secret_spec/resolved.ex @@ -20,13 +20,22 @@ defmodule SecretSpec.Resolved do @doc "Removes materialized files belonging to this resolution." def close(%__MODULE__{secrets: secrets}) do - secrets - |> Map.values() - |> Enum.filter(&(&1.as_path && is_binary(&1.path))) - |> Enum.each(fn secret -> File.rm(secret.path) |> ignore_missing_file() end) - end + first_error = + secrets + |> Map.values() + |> Enum.filter(&(&1.as_path && is_binary(&1.path))) + |> Enum.reduce(nil, fn secret, first_error -> + case File.rm(secret.path) do + :ok -> first_error + {:error, :enoent} -> first_error + {:error, reason} when is_nil(first_error) -> reason + {:error, _reason} -> first_error + end + end) - defp ignore_missing_file(:ok), do: :ok - defp ignore_missing_file({:error, :enoent}), do: :ok - defp ignore_missing_file({:error, reason}), do: raise(File.Error, reason: reason) + case first_error do + nil -> :ok + reason -> raise File.Error, reason: reason + end + end end diff --git a/secretspec-ex/mix.exs b/secretspec-ex/mix.exs index 224f9174..ae98910d 100644 --- a/secretspec-ex/mix.exs +++ b/secretspec-ex/mix.exs @@ -6,7 +6,7 @@ defmodule SecretSpec.MixProject do def project do [ app: :secretspec, - version: "0.19.1", + version: "0.20.0", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, description: "Elixir SDK for SecretSpec", @@ -25,7 +25,7 @@ defmodule SecretSpec.MixProject do defp package do [ - files: ["lib", "native", "checksum-*.exs", "mix.exs", "README.md"], + files: ["lib", "checksum-*.exs", "mix.exs", "README.md"], licenses: ["MIT"], links: %{"GitHub" => @source_url} ] @@ -36,7 +36,7 @@ defmodule SecretSpec.MixProject do {:jason, "~> 1.4"}, {:rustler_precompiled, "~> 0.9.0"}, {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}, - {:rustler, ">= 0.0.0", optional: true} + {:rustler, ">= 0.0.0", optional: true, only: [:dev, :test]} ] end end diff --git a/secretspec-ex/native/secretspec_native/Cargo.toml b/secretspec-ex/native/secretspec_native/Cargo.toml index 954981be..9d4ad053 100644 --- a/secretspec-ex/native/secretspec_native/Cargo.toml +++ b/secretspec-ex/native/secretspec_native/Cargo.toml @@ -8,4 +8,4 @@ crate-type = ["cdylib"] [dependencies] rustler = "0.38" -secretspec = { path = "../../../secretspec" } +secretspec = { workspace = true } diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index 317412c9..a7571e2d 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -174,6 +174,24 @@ defmodule SecretSpecTest do refute File.exists?(secret.path) end + @tag :tmp_dir + test "close removes later materialized files after a removal error", %{tmp_dir: tmp_dir} do + blocked_path = Path.join(tmp_dir, "blocked") + later_path = Path.join(tmp_dir, "later") + File.mkdir!(blocked_path) + File.write!(later_path, "later") + + resolved = %SecretSpec.Resolved{ + secrets: %{ + "BLOCKED" => %SecretSpec.ResolvedSecret{as_path: true, path: blocked_path}, + "LATER" => %SecretSpec.ResolvedSecret{as_path: true, path: later_path} + } + } + + assert_raise File.Error, fn -> SecretSpec.Resolved.close(resolved) end + refute File.exists?(later_path) + end + test "invalid manifest raises SecretSpec.Error" do assert_raise SecretSpec.Error, fn -> SecretSpec.builder() From 9f3be5998ac49e0079b7da2da3fe1518c92c3cfa Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 2 Sep 2026 15:14:33 +0200 Subject: [PATCH 03/20] Handle LastPass subprocess signals in Elixir SDK --- secretspec-ex/lib/secret_spec.ex | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex index 7fb637c0..55ec1d8f 100644 --- a/secretspec-ex/lib/secret_spec.ex +++ b/secretspec-ex/lib/secret_spec.ex @@ -40,7 +40,13 @@ defmodule SecretSpec do @doc false def checked_response({request, versioned}, kind, expected_version) do request_json = Jason.encode!(request) - raw = if versioned, do: Native.call(request_json), else: Native.resolve(request_json) + + # Rust's LastPass provider uses waitpid, which fails while OTP ignores SIGCHLD. + raw = + with_default_sigchld(fn -> + if versioned, do: Native.call(request_json), else: Native.resolve(request_json) + end) + envelope = Jason.decode!(raw) case envelope do @@ -60,6 +66,20 @@ defmodule SecretSpec do end @doc false + defp with_default_sigchld(fun) do + :global.trans({__MODULE__, :sigchld}, fn -> + :ok = :os.set_signal(:sigchld, :default) + + try do + fun.() + after + # OTP normally starts with SIGCHLD ignored, but the public Erlang API has no + # getter, so restore that assumed original disposition. + :ok = :os.set_signal(:sigchld, :ignore) + end + end) + end + def to_resolved(%{"missing_required" => [_ | _] = missing}), do: raise(%MissingRequiredError{missing: missing}) From 7bbd2f778dd179c9eccb9db4bb72121df4cf75ab Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Mon, 7 Sep 2026 15:48:20 +0200 Subject: [PATCH 04/20] Complete Elixir SDK integration --- docs/src/content/docs/sdk/elixir.mdx | 2 +- docs/src/pages/index.astro | 20 +++- secretspec-ex/README.md | 2 +- secretspec-ex/lib/secret_spec.ex | 50 ++++++---- secretspec-ex/lib/secret_spec/builder.ex | 114 +++++++++++++++-------- secretspec-ex/lib/secret_spec/native.ex | 20 +++- secretspec-ex/lib/secret_spec/report.ex | 2 +- secretspec-ex/mix.exs | 2 +- secretspec-ex/test/secret_spec_test.exs | 89 +++++++++++++++--- secretspec/README.md | 1 + 10 files changed, 221 insertions(+), 81 deletions(-) diff --git a/docs/src/content/docs/sdk/elixir.mdx b/docs/src/content/docs/sdk/elixir.mdx index 69fba711..24c00b49 100644 --- a/docs/src/content/docs/sdk/elixir.mdx +++ b/docs/src/content/docs/sdk/elixir.mdx @@ -59,7 +59,7 @@ Use `with_scope/2` to resolve a named `[scopes.]` subset. The selected nam ## Inline specifications -Use `with_inline_spec/3` when the declaration lives in application code. The base directory controls relative provider paths, while the Rust core validates the strict inline-spec v1 envelope. +Use `with_inline_spec/3` when the declaration lives in application code. The base directory controls relative provider paths, while the Rust core validates the strict inline-spec v2 (0.21+) envelope. ## Lifetime and file secrets diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 1fbf0271..7e82bcf6 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/secretspec-ex/README.md b/secretspec-ex/README.md index a064ecdc..649293a7 100644 --- a/secretspec-ex/README.md +++ b/secretspec-ex/README.md @@ -1,6 +1,6 @@ # SecretSpec Elixir SDK -The Elixir SDK is a thin Rustler binding over the shared SecretSpec Rust +The Elixir SDK (0.21+) is a thin Rustler binding over the shared SecretSpec Rust resolver. The Elixir layer builds the JSON request and exposes the response as small structs; providers, profiles, fallback chains, and materialized paths remain in Rust. diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex index 55ec1d8f..ce56bf93 100644 --- a/secretspec-ex/lib/secret_spec.ex +++ b/secretspec-ex/lib/secret_spec.ex @@ -47,41 +47,58 @@ defmodule SecretSpec do if versioned, do: Native.call(request_json), else: Native.resolve(request_json) end) - envelope = Jason.decode!(raw) + raw + |> Jason.decode!() + |> checked_envelope(kind, expected_version) + end + @doc false + def checked_envelope(envelope, kind, expected_version) do case envelope do %{"ok" => true, "response" => %{"schema_version" => ^expected_version} = response} -> response - %{"ok" => true, "response" => response} -> + %{"ok" => true, "response" => response} when is_map(response) -> raise %Error{ kind: "version", message: "unsupported #{kind} schema version #{response["schema_version"]} (expected #{expected_version})" } + %{"ok" => true} -> + raise %Error{kind: "ffi", message: "secretspec_#{kind} reported ok with no response"} + %{"ok" => false, "error" => %{"kind" => error_kind, "message" => message}} -> raise %Error{kind: error_kind, message: message} + + _ -> + raise %Error{kind: "ffi", message: "invalid secretspec_#{kind} response envelope"} end end - @doc false defp with_default_sigchld(fun) do - :global.trans({__MODULE__, :sigchld}, fn -> - :ok = :os.set_signal(:sigchld, :default) - - try do + case :os.type() do + {:unix, _} -> + :global.trans({__MODULE__, self()}, fn -> + :ok = :os.set_signal(:sigchld, :default) + + try do + fun.() + after + # OTP normally starts with SIGCHLD ignored, but the public Erlang API has no + # getter, so restore that assumed original disposition. + :ok = :os.set_signal(:sigchld, :ignore) + end + end) + + {:win32, _} -> fun.() - after - # OTP normally starts with SIGCHLD ignored, but the public Erlang API has no - # getter, so restore that assumed original disposition. - :ok = :os.set_signal(:sigchld, :ignore) - end - end) + end end - def to_resolved(%{"missing_required" => [_ | _] = missing}), - do: raise(%MissingRequiredError{missing: missing}) + def to_resolved(%{"missing_required" => [_ | _] = missing}) do + raise %MissingRequiredError{missing: missing} + end def to_resolved(response) do secrets = @@ -124,7 +141,8 @@ defmodule SecretSpec do provider: response["provider"], profile: response["profile"], secrets: secrets, - scope: response["scope"] + scope: response["scope"], + constraint_violations: response["constraint_violations"] || [] } end end diff --git a/secretspec-ex/lib/secret_spec/builder.ex b/secretspec-ex/lib/secret_spec/builder.ex index 0a1d617e..4bd9d939 100644 --- a/secretspec-ex/lib/secret_spec/builder.ex +++ b/secretspec-ex/lib/secret_spec/builder.ex @@ -6,27 +6,46 @@ defmodule SecretSpec.Builder do @doc "Creates an empty builder." def new, do: %__MODULE__{} + @doc "Sets the manifest path." - def with_path(%__MODULE__{} = builder, path), - do: %{builder | request: Map.put(builder.request, "path", path), inline: nil} + def with_path(%__MODULE__{} = builder, path) do + %{builder | request: Map.put(builder.request, "path", path), inline: nil} + end @doc "Sets the provider URI or name." - def with_provider(%__MODULE__{} = builder, provider), do: put(builder, "provider", provider) + def with_provider(%__MODULE__{} = builder, provider) do + put(builder, "provider", provider) + end + @doc "Sets the profile." - def with_profile(%__MODULE__{} = builder, profile), do: put(builder, "profile", profile) + def with_profile(%__MODULE__{} = builder, profile) do + put(builder, "profile", profile) + end + @doc "Sets the manifest scope." - def with_scope(%__MODULE__{} = builder, scope), do: put(builder, "scope", scope) + def with_scope(%__MODULE__{} = builder, scope) do + put(builder, "scope", scope) + end + @doc "Sets the audit reason." - def with_reason(%__MODULE__{} = builder, reason), do: put(builder, "reason", reason) + def with_reason(%__MODULE__{} = builder, reason) do + put(builder, "reason", reason) + end + @doc "Sets caller audit context." - def with_caller(%__MODULE__{} = builder, caller), - do: put(builder, "caller", CallerContext.to_request(caller)) + def with_caller(%__MODULE__{} = builder, caller) do + put(builder, "caller", CallerContext.to_request(caller)) + end @doc "Controls whether values are omitted." - def with_no_values(%__MODULE__{} = builder, value \\ true), do: put(builder, "no_values", value) + def with_no_values(%__MODULE__{} = builder, value \\ true) do + put(builder, "no_values", value) + end + @doc "Sets an inline specification and its logical base directory." - def with_inline_spec(%__MODULE__{} = builder, spec, base_dir), - do: %{builder | request: Map.delete(builder.request, "path"), inline: {spec, base_dir}} + def with_inline_spec(%__MODULE__{} = builder, spec, base_dir) do + %{builder | request: Map.delete(builder.request, "path"), inline: {spec, base_dir}} + end @doc "Applies one-shot options to a builder." def configure(builder, opts) do @@ -42,40 +61,53 @@ defmodule SecretSpec.Builder do end @doc "Resolves and returns a `SecretSpec.Resolved`." - def load(%__MODULE__{} = builder), - do: - builder - |> native_request() - |> SecretSpec.checked_response("resolve", 2) - |> SecretSpec.to_resolved() + def load(%__MODULE__{} = builder) do + builder + |> native_request() + |> SecretSpec.checked_response("resolve", 2) + |> SecretSpec.to_resolved() + end @doc "Resolves without returning secret values." - def report(%__MODULE__{} = builder), - do: - builder - |> native_request("report") - |> SecretSpec.checked_response("report", 1) - |> SecretSpec.to_report() - - defp put(builder, key, value), do: %{builder | request: Map.put(builder.request, key, value)} - defp native_request(%__MODULE__{request: request, inline: nil}), do: {request, false} - - defp native_request(%__MODULE__{request: request, inline: {spec, base_dir}}), - do: - {%{ - "request_version" => 1, - "operation" => "resolve", - "source" => %{ - "kind" => "inline", - "spec_version" => 1, - "base_dir" => base_dir, - "spec" => spec - }, - "options" => request - }, true} + def report(%__MODULE__{} = builder) do + builder + |> native_request("report") + |> SecretSpec.checked_response("report", 1) + |> SecretSpec.to_report() + end + + defp put(builder, key, value) do + %{builder | request: Map.put(builder.request, key, value)} + end + + defp native_request(%__MODULE__{request: request, inline: nil}) do + {request, false} + end + + defp native_request(%__MODULE__{request: request, inline: {spec, base_dir}}) do + {%{ + "request_version" => 1, + "operation" => "resolve", + "source" => %{ + "kind" => "inline", + "spec_version" => 2, + "base_dir" => base_dir, + "spec" => spec + }, + "options" => request + }, true} + end defp native_request(builder, mode) do {request, versioned} = native_request(builder) - {Map.put(request, "mode", mode), versioned} + {put_mode(request, mode, versioned), versioned} + end + + defp put_mode(%{"options" => options} = request, mode, true) do + %{request | "options" => Map.put(options, "mode", mode)} + end + + defp put_mode(request, mode, false) do + Map.put(request, "mode", mode) end end diff --git a/secretspec-ex/lib/secret_spec/native.ex b/secretspec-ex/lib/secret_spec/native.ex index 378a6fb8..a0bf7ea0 100644 --- a/secretspec-ex/lib/secret_spec/native.ex +++ b/secretspec-ex/lib/secret_spec/native.ex @@ -3,8 +3,8 @@ defmodule SecretSpec.Native do version = Mix.Project.config()[:version] - # local_checkout? = - # File.exists?(Path.expand("../../native/secretspec_native/Cargo.toml", __DIR__)) + local_checkout? = + File.exists?(Path.expand("../../native/secretspec_native/Cargo.toml", __DIR__)) use RustlerPrecompiled, otp_app: :secretspec, @@ -16,10 +16,20 @@ defmodule SecretSpec.Native do "x86_64-pc-windows-msvc" ], base_url: "https://github.com/cachix/secretspec/releases/download/v#{version}", - # force_build: local_checkout?, + force_build: local_checkout?, version: version - def resolve(_request), do: :erlang.nif_error(:nif_not_loaded) - def call(_request), do: :erlang.nif_error(:nif_not_loaded) + def resolve(_request) do + :erlang.nif_error(:nif_not_loaded) + end + + # Rustler replaces this fallback when the loaded NIF exports `call/1`. + def call(_request) do + raise %SecretSpec.Error{ + kind: "capability", + message: "loaded libsecretspec does not support inline specs (missing secretspec_call)" + } + end + def abi_version, do: :erlang.nif_error(:nif_not_loaded) end diff --git a/secretspec-ex/lib/secret_spec/report.ex b/secretspec-ex/lib/secret_spec/report.ex index be3e44d7..306e76f4 100644 --- a/secretspec-ex/lib/secret_spec/report.ex +++ b/secretspec-ex/lib/secret_spec/report.ex @@ -1,5 +1,5 @@ defmodule SecretSpec.Report do @moduledoc "A value-free resolution snapshot." - defstruct [:provider, :profile, :secrets, scope: nil] + defstruct [:provider, :profile, :secrets, scope: nil, constraint_violations: []] end diff --git a/secretspec-ex/mix.exs b/secretspec-ex/mix.exs index ae98910d..807a0e10 100644 --- a/secretspec-ex/mix.exs +++ b/secretspec-ex/mix.exs @@ -36,7 +36,7 @@ defmodule SecretSpec.MixProject do {:jason, "~> 1.4"}, {:rustler_precompiled, "~> 0.9.0"}, {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}, - {:rustler, ">= 0.0.0", optional: true, only: [:dev, :test]} + {:rustler, ">= 0.0.0", optional: true} ] end end diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index a7571e2d..4f2bd94a 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -57,26 +57,35 @@ defmodule SecretSpecTest do resolved = SecretSpec.builder() - |> SecretSpec.Builder.with_inline_spec( - %{ - "project" => %{"name" => "elixir-inline"}, - "providers" => %{"env" => "dotenv://inline.env"}, - "profiles" => %{ - "default" => %{ - "secrets" => %{ - "TOKEN" => %{"description" => "token", "providers" => ["env"]} - } - } - } - }, - base_dir - ) + |> SecretSpec.Builder.with_inline_spec(inline_spec(), base_dir) |> SecretSpec.Builder.with_reason("inline test") |> SecretSpec.Builder.load() assert resolved.secrets["TOKEN"].value == "inline-elixir" end + test "inline spec returns a value-free report" do + {manifest, _provider} = project("") + base_dir = Path.dirname(manifest) + File.write!(Path.join(base_dir, "inline.env"), "TOKEN=inline-elixir\n") + + report = + SecretSpec.builder() + |> SecretSpec.Builder.with_inline_spec(inline_spec(), base_dir) + |> SecretSpec.Builder.report() + + assert %SecretSpec.Report{ + profile: "default", + secrets: [ + %SecretSpec.SecretReport{ + name: "TOKEN", + status: "resolved", + required: true + } + ] + } = report + end + test "scope is selected and returned" do {manifest, provider} = project("DATABASE_URL=postgres://db\nSENTRY_DSN=https://sentry\n") @@ -120,6 +129,35 @@ defmodule SecretSpecTest do assert Enum.find(report.secrets, &(&1.name == "SENTRY_DSN")).status == "missing_optional" end + test "report preserves constraint violations" do + manifest = """ + [project] + name = "constraint-report" + revision = "1.0" + + [profiles.default] + PASSWORD = { description = "Account password", required = { at_least_one = "account_auth" } } + ACCESS_TOKEN = { description = "Personal access token", required = { at_least_one = "account_auth" } } + """ + + {manifest_path, provider} = project("", manifest) + + report = + SecretSpec.builder() + |> SecretSpec.Builder.with_path(manifest_path) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.report() + + assert [ + %{ + "kind" => "at_least_one", + "group" => "account_auth", + "secrets" => ["ACCESS_TOKEN", "PASSWORD"], + "present" => [] + } + ] = report.constraint_violations + end + test "no_values returns nil fields" do {manifest, provider} = project("DATABASE_URL=postgres://db\n") @@ -200,6 +238,15 @@ defmodule SecretSpecTest do end end + test "an ok envelope without a response raises an ffi error" do + error = + assert_raise SecretSpec.Error, fn -> + SecretSpec.checked_envelope(%{"ok" => true}, "resolve", 2) + end + + assert error.kind == "ffi" + end + test "set_as_env exports resolved secrets" do {manifest, provider} = project("DATABASE_URL=postgres://db\n") System.delete_env("DATABASE_URL") @@ -229,6 +276,20 @@ defmodule SecretSpecTest do {manifest_path, "dotenv://#{env_path}"} end + defp inline_spec do + %{ + "project" => %{"name" => "elixir-inline"}, + "providers" => %{"env" => "dotenv://inline.env"}, + "profiles" => %{ + "default" => %{ + "secrets" => %{ + "TOKEN" => %{"description" => "token", "providers" => ["env"]} + } + } + } + } + end + defp manifest do """ [project] diff --git a/secretspec/README.md b/secretspec/README.md index f1012e95..57f1bc6a 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) (Rustler binding with precompiled NIFs) ```python from secretspec import SecretSpec From 4ae73a6e6fb3af88029f54684e15ac73225ada2a Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Mon, 7 Sep 2026 16:25:14 +0200 Subject: [PATCH 05/20] Expand Elixir SDK conformance coverage and harden tests - Enumerate all conformance fixtures and validate resolve, no-values, and report projections per fixture (mirrors Node/Python SDKs) - Add envelope hardening tests for unsupported schema_version and invalid envelopes in checked_envelope/3 - Replace builder change-detector test with observable profile selection behavior - Convert bound function heads to do...end blocks per project style --- secretspec-ex/lib/secret_spec/error.ex | 4 +- .../lib/secret_spec/missing_required_error.ex | 5 +- secretspec-ex/lib/secret_spec/resolved.ex | 5 +- .../lib/secret_spec/resolved_secret.ex | 9 +- secretspec-ex/test/conformance_test.exs | 92 ++++++++++++++++--- secretspec-ex/test/secret_spec_test.exs | 58 +++++++++--- 6 files changed, 139 insertions(+), 34 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/error.ex b/secretspec-ex/lib/secret_spec/error.ex index e24b674c..0426e361 100644 --- a/secretspec-ex/lib/secret_spec/error.ex +++ b/secretspec-ex/lib/secret_spec/error.ex @@ -4,5 +4,7 @@ defmodule SecretSpec.Error do defexception [:kind, :message] @impl true - def message(%__MODULE__{kind: kind, message: message}), do: "#{message} (kind: #{kind})" + def message(%__MODULE__{kind: kind, message: message}) do + "#{message} (kind: #{kind})" + end end diff --git a/secretspec-ex/lib/secret_spec/missing_required_error.ex b/secretspec-ex/lib/secret_spec/missing_required_error.ex index 5d2631a1..905d5791 100644 --- a/secretspec-ex/lib/secret_spec/missing_required_error.ex +++ b/secretspec-ex/lib/secret_spec/missing_required_error.ex @@ -4,6 +4,7 @@ defmodule SecretSpec.MissingRequiredError do defexception [:missing] @impl true - def message(%__MODULE__{missing: missing}), - do: "missing required secret(s): #{Enum.join(missing, ", ")}" + def message(%__MODULE__{missing: missing}) do + "missing required secret(s): #{Enum.join(missing, ", ")}" + end end diff --git a/secretspec-ex/lib/secret_spec/resolved.ex b/secretspec-ex/lib/secret_spec/resolved.ex index 419bd9b4..4ca97803 100644 --- a/secretspec-ex/lib/secret_spec/resolved.ex +++ b/secretspec-ex/lib/secret_spec/resolved.ex @@ -15,8 +15,9 @@ defmodule SecretSpec.Resolved do end @doc "Returns a flat map of secret names to usable values." - def fields(%__MODULE__{secrets: secrets}), - do: Map.new(secrets, fn {name, secret} -> {name, ResolvedSecret.get(secret)} end) + def fields(%__MODULE__{secrets: secrets}) do + Map.new(secrets, fn {name, secret} -> {name, ResolvedSecret.get(secret)} end) + end @doc "Removes materialized files belonging to this resolution." def close(%__MODULE__{secrets: secrets}) do diff --git a/secretspec-ex/lib/secret_spec/resolved_secret.ex b/secretspec-ex/lib/secret_spec/resolved_secret.ex index 7c8511ad..f4d84d70 100644 --- a/secretspec-ex/lib/secret_spec/resolved_secret.ex +++ b/secretspec-ex/lib/secret_spec/resolved_secret.ex @@ -4,6 +4,11 @@ defmodule SecretSpec.ResolvedSecret do defstruct [:value, :path, :as_path, :source, :source_provider] @doc "Returns the usable value or materialized file path." - def get(%__MODULE__{as_path: true, path: path}), do: path - def get(%__MODULE__{value: value}), do: value + def get(%__MODULE__{as_path: true, path: path}) do + path + end + + def get(%__MODULE__{value: value}) do + value + end end diff --git a/secretspec-ex/test/conformance_test.exs b/secretspec-ex/test/conformance_test.exs index 869ffde6..f19071af 100644 --- a/secretspec-ex/test/conformance_test.exs +++ b/secretspec-ex/test/conformance_test.exs @@ -3,20 +3,66 @@ defmodule SecretSpec.ConformanceTest do @fixtures Path.expand("../../conformance/fixtures", __DIR__) - test "matches the canonical basic fixture" do - fixture = Path.join(@fixtures, "basic") - expected = fixture |> Path.join("expected.json") |> File.read!() |> Jason.decode!() - manifest = Path.join(fixture, "secretspec.toml") - provider = "dotenv://#{Path.join(fixture, ".env")}" - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.with_reason("conformance") - |> SecretSpec.Builder.load() - - assert canonical(resolved) == expected + fixtures = + @fixtures |> File.ls!() |> Enum.filter(&File.dir?(Path.join(@fixtures, &1))) |> Enum.sort() + + for fixture <- fixtures do + @fixture fixture + + test "matches the canonical #{@fixture} fixture" do + fixture_path = Path.join(@fixtures, @fixture) + expected = fixture_path |> Path.join("expected.json") |> File.read!() |> Jason.decode!() + resolved = fixture_path |> builder() |> SecretSpec.Builder.load() + + try do + assert canonical(resolved) == expected + after + :ok = SecretSpec.Resolved.close(resolved) + end + end + + test "matches the no-values #{@fixture} fixture" do + fixture_path = Path.join(@fixtures, @fixture) + + expected = + fixture_path + |> Path.join("expected_no_values.json") + |> File.read!() + |> Jason.decode!() + + resolved = + fixture_path + |> builder() + |> SecretSpec.Builder.with_no_values() + |> SecretSpec.Builder.load() + + try do + assert SecretSpec.Resolved.fields(resolved) == expected + after + :ok = SecretSpec.Resolved.close(resolved) + end + end + + test "matches the report #{@fixture} fixture" do + fixture_path = Path.join(@fixtures, @fixture) + + expected = + fixture_path + |> Path.join("expected_report.json") + |> File.read!() + |> Jason.decode!() + + report = fixture_path |> builder() |> SecretSpec.Builder.report() + + assert canonical_report(report) == expected + end + end + + defp builder(fixture_path) do + SecretSpec.builder() + |> SecretSpec.Builder.with_path(Path.join(fixture_path, "secretspec.toml")) + |> SecretSpec.Builder.with_provider("dotenv://#{Path.join(fixture_path, ".env")}") + |> SecretSpec.Builder.with_reason("conformance") end defp canonical(resolved) do @@ -33,4 +79,22 @@ defmodule SecretSpec.ConformanceTest do "missing_optional" => Enum.sort(resolved.missing_optional) } end + + defp canonical_report(report) do + %{ + "profile" => report.profile, + "secrets" => + Map.new(report.secrets, fn secret -> + {secret.name, + %{ + "status" => secret.status, + "required" => secret.required, + "as_path" => secret.as_path, + "generated" => secret.generated, + "default_applied" => secret.default_applied, + "source_provider" => not is_nil(secret.source_provider) + }} + end) + } + end end diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index 4f2bd94a..8b69cc22 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -1,20 +1,30 @@ defmodule SecretSpecTest do use ExUnit.Case, async: true - test "builder stores request options" do - builder = + test "profile selects its default value" do + manifest = """ + [project] + name = "profile-selection" + revision = "1.0" + + [profiles.default] + API_KEY = { description = "API key", required = false, default = "default" } + + [profiles.production] + API_KEY = { description = "API key", required = false, default = "production" } + """ + + {manifest_path, provider} = project("", manifest) + + resolved = SecretSpec.builder() - |> SecretSpec.Builder.with_path("secretspec.toml") - |> SecretSpec.Builder.with_provider("dotenv://.env") - |> SecretSpec.Builder.with_profile("development") - |> SecretSpec.Builder.with_reason("test") - - assert builder.request == %{ - "path" => "secretspec.toml", - "provider" => "dotenv://.env", - "profile" => "development", - "reason" => "test" - } + |> SecretSpec.Builder.with_path(manifest_path) + |> SecretSpec.Builder.with_provider(provider) + |> SecretSpec.Builder.with_profile("production") + |> SecretSpec.Builder.load() + + assert resolved.profile == "production" + assert resolved.secrets["API_KEY"].value == "production" end test "caller context omits unset fields" do @@ -247,6 +257,28 @@ defmodule SecretSpecTest do assert error.kind == "ffi" end + test "an unsupported schema version raises a version error" do + error = + assert_raise SecretSpec.Error, fn -> + SecretSpec.checked_envelope( + %{"ok" => true, "response" => %{"schema_version" => 999}}, + "resolve", + 2 + ) + end + + assert error.kind == "version" + end + + test "an invalid envelope raises an ffi error" do + error = + assert_raise SecretSpec.Error, fn -> + SecretSpec.checked_envelope(%{"unexpected" => true}, "resolve", 2) + end + + assert error.kind == "ffi" + end + test "set_as_env exports resolved secrets" do {manifest, provider} = project("DATABASE_URL=postgres://db\n") System.delete_env("DATABASE_URL") From c5c97c2204df3fc32e9d0bdc0646a63a61e56d0b Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Mon, 7 Sep 2026 19:22:53 +0200 Subject: [PATCH 06/20] Gate the Elixir SIGCHLD flip on subprocess-capable requests and unify NIF builds The SIGCHLD restore now runs only when the request may reach a subprocess-spawning provider, instead of on every native call. The gate inspects the request's provider spec, inline aliases, per-secret chains, profile defaults, and options; anything it cannot prove in-process (aliases, bare names, unknown schemes) stays fail-safe and engages the flip. Scheme lists mirror the core's provider registrations, including akv, whose Azure CLI credential shells out via azure_identity. The Elixir release workflow folds nif-linux and nif-native into one matrix job matching the sibling SDK workflows, removing the duplicated steps and the divergent toolchain pinning. --- .github/workflows/elixir-package.yml | 58 ++------ CHANGELOG.md | 2 +- secretspec-ex/lib/secret_spec.ex | 19 ++- secretspec-ex/lib/secret_spec/subprocess.ex | 138 ++++++++++++++++++++ secretspec-ex/test/subprocess_test.exs | 121 +++++++++++++++++ 5 files changed, 283 insertions(+), 55 deletions(-) create mode 100644 secretspec-ex/lib/secret_spec/subprocess.ex create mode 100644 secretspec-ex/test/subprocess_test.exs diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml index a69046e0..cd7c36c8 100644 --- a/.github/workflows/elixir-package.yml +++ b/.github/workflows/elixir-package.yml @@ -64,7 +64,7 @@ jobs: working-directory: secretspec-ex run: mix test - nif-linux: + nif: name: NIF ${{ matrix.target }} if: >- inputs.pre_release || @@ -72,7 +72,7 @@ jobs: !startsWith(github.event.pull_request.title, 'Release ') needs: [test] runs-on: ${{ matrix.runner }} - container: ${{ matrix.container }} + container: ${{ matrix.container || null }} strategy: fail-fast: false matrix: @@ -83,12 +83,15 @@ jobs: - target: aarch64-unknown-linux-gnu runner: ubuntu-24.04-arm container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.05-1@sha256:f766b402889e40f439e7a3ee5788eef1aa3ef399d0110107d27419fa2ba9d905 + - { target: aarch64-apple-darwin, runner: macos-latest } + - { target: x86_64-pc-windows-msvc, runner: windows-latest } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false submodules: recursive - - name: Install verified rustup + - name: Install verified rustup (the manylinux container ships none) + if: matrix.container shell: bash run: | bash scripts/install-rustup.sh @@ -108,7 +111,8 @@ jobs: target: ${{ matrix.target }} nif-version: "2.15" project-dir: secretspec-ex/native/secretspec_native - - name: Verify Linux portability + - name: Verify Linux portability (glibc <= 2.28, no libdbus) + if: matrix.container shell: bash run: >- bash scripts/check-linux-portability.sh @@ -121,53 +125,9 @@ jobs: name: elixir-nif-${{ matrix.target }} path: ${{ steps.build.outputs.file-path }} - nif-native: - name: NIF ${{ matrix.target }} - if: >- - inputs.pre_release || - github.event_name != 'pull_request' || - !startsWith(github.event.pull_request.title, 'Release ') - needs: [test] - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - { target: aarch64-apple-darwin, runner: macos-latest } - - { target: x86_64-pc-windows-msvc, runner: windows-latest } - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - submodules: recursive - - name: Install Rust (pinned by rust-toolchain.toml) - uses: dtolnay/rust-toolchain@stable - with: - target: ${{ matrix.target }} - - name: Sync SDK package versions - run: bash scripts/sync-sdk-versions.sh - - name: Build Rustler NIF - id: build - uses: philss/rustler-precompiled-action@v1.1.5 - env: - CARGO_TARGET_DIR: target - with: - project-name: secretspec_native - project-version: ${{ env.PROJECT_VERSION }} - target: ${{ matrix.target }} - nif-version: "2.15" - project-dir: secretspec-ex/native/secretspec_native - - name: Verify generated archive - shell: bash - run: test -s "${{ steps.build.outputs.file-path }}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: elixir-nif-${{ matrix.target }} - path: ${{ steps.build.outputs.file-path }} - publish-nifs: name: Publish Elixir NIFs - needs: [nif-linux, nif-native] + needs: [nif] if: >- (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || inputs.release_tag != '' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4bb1cd..05ac6f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [Unreleased] ### Added diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex index ce56bf93..f8766d3a 100644 --- a/secretspec-ex/lib/secret_spec.ex +++ b/secretspec-ex/lib/secret_spec.ex @@ -14,7 +14,8 @@ defmodule SecretSpec do Report, Resolved, ResolvedSecret, - SecretReport + SecretReport, + Subprocess } @doc "Starts a fluent SecretSpec builder." @@ -41,11 +42,19 @@ defmodule SecretSpec do def checked_response({request, versioned}, kind, expected_version) do request_json = Jason.encode!(request) - # Rust's LastPass provider uses waitpid, which fails while OTP ignores SIGCHLD. + # CLI-backed providers (LastPass, 1Password, …) waitpid, which fails while + # OTP ignores SIGCHLD. The flip is VM-wide, so engage it only for requests + # that may reach such a provider; Subprocess stays fail-safe. + native_call = fn -> + if versioned, do: Native.call(request_json), else: Native.resolve(request_json) + end + raw = - with_default_sigchld(fn -> - if versioned, do: Native.call(request_json), else: Native.resolve(request_json) - end) + if Subprocess.engaged_by?(request) do + with_default_sigchld(native_call) + else + native_call.() + end raw |> Jason.decode!() diff --git a/secretspec-ex/lib/secret_spec/subprocess.ex b/secretspec-ex/lib/secret_spec/subprocess.ex new file mode 100644 index 00000000..df9d0290 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/subprocess.ex @@ -0,0 +1,138 @@ +defmodule SecretSpec.Subprocess do + @moduledoc """ + Detects whether a native request may reach a provider that spawns a + subprocess. + + The BEAM starts with SIGCHLD ignored, which breaks Rust's `waitpid`-based + CLI providers (`lpass`, `op`, `bw`, …). The NIF bridge restores SIGCHLD + before such calls, but that flip is a VM-wide signal mutation, so it must + only run when a subprocess-backed provider is actually in play. This module + inspects the request the way the builder built it: the explicit provider + spec, the inline spec's provider aliases, per-secret provider chains, and + profile defaults. + + The scheme lists below mirror the core's provider registrations + (`secretspec/src/provider/*.rs`); a new CLI-backed registration must be + added to `@subprocess_schemes` here. + """ + + @subprocess_schemes [ + "akv", + "bw", + "bws", + "cloudflare", + "dashlane", + "ejson", + "fly", + "gopass", + "lastpass", + "onepassword", + "onepassword+token", + "op", + "pass", + "passbolt", + "protonpass", + "sops" + ] + + @in_process_schemes [ + "aac", + "age", + "awsps", + "awssm", + "cloudflarekvs", + "dotenv", + "env", + "file", + "gcsm", + "infisical", + "kdbx", + "keeper", + "keyring", + "k8s+configmap", + "k8s+secret", + "kubernetes", + "openbao", + "scaleway", + "systemd-credential", + "vault" + ] + + @doc """ + Returns true when the request may reach a subprocess-spawning provider. + + Fail-safe: any provider spec the SDK cannot prove subprocess-free (an + alias name, a bare provider name, a scheme missing from both lists, an + unknown shape) engages the wrapper. Only a URI whose scheme names a known + in-process provider skips it. + """ + @spec engaged_by?(map()) :: boolean() + def engaged_by?(request) when is_map(request) do + request + |> provider_specs() + |> Enum.any?(&subprocess_possible?/1) + end + + defp provider_specs(%{"provider" => provider}) when is_binary(provider) do + [provider] + end + + defp provider_specs(%{"source" => %{"kind" => "inline"} = source} = request) do + option = + case request do + %{"options" => %{"provider" => provider}} when is_binary(provider) -> [provider] + _ -> [] + end + + specs = alias_specs(source) ++ chain_specs(source) ++ default_specs(source) ++ option + + case specs do + [] -> [nil] + specs -> specs + end + end + + # A path-based request without an explicit provider: the core falls back to + # the user config default provider, which the SDK cannot inspect. The + # manifest's own aliases could name a subprocess provider, so stay safe. + defp provider_specs(_), do: [nil] + + defp alias_specs(%{"spec" => %{"providers" => providers}}) when is_map(providers) do + Enum.map(providers, fn {_name, spec} -> spec end) + end + + defp alias_specs(_), do: [] + + defp chain_specs(%{"spec" => %{"profiles" => profiles}}) when is_map(profiles) do + Enum.flat_map(profiles, fn + {_name, %{"secrets" => secrets}} when is_map(secrets) -> + Enum.flat_map(secrets, fn + {_name, %{"providers" => chain}} when is_list(chain) -> chain + _ -> [] + end) + + _ -> + [] + end) + end + + defp chain_specs(_), do: [] + + defp default_specs(%{"spec" => %{"defaults" => %{"providers" => providers}}}) + when is_list(providers) do + providers + end + + defp default_specs(_), do: [] + + defp subprocess_possible?(spec) when is_binary(spec) do + case URI.parse(spec).scheme do + scheme when scheme in @subprocess_schemes -> true + scheme when scheme in @in_process_schemes -> false + scheme when is_binary(scheme) -> true + nil -> true + end + end + + defp subprocess_possible?(_), do: true +end diff --git a/secretspec-ex/test/subprocess_test.exs b/secretspec-ex/test/subprocess_test.exs new file mode 100644 index 00000000..0c6d0f45 --- /dev/null +++ b/secretspec-ex/test/subprocess_test.exs @@ -0,0 +1,121 @@ +defmodule SecretSpec.SubprocessTest do + use ExUnit.Case, async: true + + # A provider spec the gate cannot prove in-process (an alias, a bare name, + # or any non-URI string) must engage the wrapper; only a URI whose scheme + # names a known in-process provider may skip it. + + test "a URI with an in-process scheme does not engage" do + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "dotenv://.env"}) + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "keyring://"}) + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "env://"}) + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "file:///tmp/secrets"}) + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "vault://cluster"}) + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "awssm://us-east-1/name"}) + end + + test "a URI with a CLI-backed scheme engages" do + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "lastpass://Work"}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "op://Team"}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "sops://config.yaml"}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "pass://"}) + # Azure CLI credential shells out to `az` via azure_identity. + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "akv://my-vault"}) + end + + test "the single-colon shorthand carries its scheme, matching the core's split_spec" do + refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "dotenv:.env"}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "sops:config.yaml"}) + end + + test "a scheme missing from both lists engages (fail-safe)" do + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "futureprovider://x"}) + end + + test "an alias or bare provider name engages" do + # Aliases resolve through manifest and user config the SDK cannot see; + # the gate stays fail-safe. + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "prod_vault"}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "keyring"}) + end + + test "a path request without an explicit provider engages" do + # The core then consults the user config default provider, invisible to + # the SDK. + assert SecretSpec.Subprocess.engaged_by?(%{"path" => "secretspec.toml"}) + end + + test "an inline spec is judged by its own provider declarations" do + inline = fn providers -> + %{ + "request_version" => 1, + "operation" => "resolve", + "source" => %{ + "kind" => "inline", + "spec_version" => 2, + "base_dir" => "/tmp", + "spec" => providers + }, + "options" => %{} + } + end + + dotenv_only = %{ + "providers" => %{"env" => "dotenv://inline.env"}, + "profiles" => %{ + "default" => %{ + "secrets" => %{ + "TOKEN" => %{"description" => "t", "providers" => ["dotenv://inline.env"]} + } + } + } + } + + refute SecretSpec.Subprocess.engaged_by?(inline.(dotenv_only)) + + with_lastpass_alias = put_in(dotenv_only, ["providers", "vault"], "lastpass://Work") + assert SecretSpec.Subprocess.engaged_by?(inline.(with_lastpass_alias)) + + with_fallback_chain = + put_in(dotenv_only, ["profiles", "default", "secrets", "TOKEN", "providers"], [ + "env", + "op://Team" + ]) + + assert SecretSpec.Subprocess.engaged_by?(inline.(with_fallback_chain)) + + with_subprocess_default = + put_in(dotenv_only, ["defaults"], %{"providers" => ["sops://secrets.yaml"]}) + + assert SecretSpec.Subprocess.engaged_by?(inline.(with_subprocess_default)) + + # The provider can also ride in options alongside an inline spec + # (Builder.with_inline_spec |> with_provider puts it there). + with_option = + put_in(inline.(dotenv_only), ["options", "provider"], "lastpass://Work") + + assert SecretSpec.Subprocess.engaged_by?(with_option) + end + + test "an inline spec without any provider declaration engages" do + # The core then uses the user config default provider. + request = %{ + "request_version" => 1, + "operation" => "resolve", + "source" => %{ + "kind" => "inline", + "spec_version" => 2, + "base_dir" => "/tmp", + "spec" => %{"project" => %{"name" => "x"}} + }, + "options" => %{} + } + + assert SecretSpec.Subprocess.engaged_by?(request) + end + + test "an unexpected request shape engages" do + assert SecretSpec.Subprocess.engaged_by?(%{}) + assert SecretSpec.Subprocess.engaged_by?(%{"provider" => 42}) + end +end From 7ada2f4ceb63c03d05615b2eb6098e4061fe2bb3 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Thu, 17 Sep 2026 09:27:48 +0200 Subject: [PATCH 07/20] Allow easy force-building --- devenv.nix | 2 ++ secretspec-ex/lib/secret_spec/native.ex | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/devenv.nix b/devenv.nix index c1e92215..774700ca 100644 --- a/devenv.nix +++ b/devenv.nix @@ -115,6 +115,8 @@ env = { # Gradle runs on JDK 21 but compiles the JVM SDK against this JDK 11 toolchain. SECRETSPEC_JVM_TARGET_JDK = "${pkgs.jdk11}"; + # Force-build the development version of the Elixir SDK + SECRETSPEC_EX_FORCE_BUILD = true; } // # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). # Keep these Linux-only: interpolating the cross-toolchain paths on macOS makes diff --git a/secretspec-ex/lib/secret_spec/native.ex b/secretspec-ex/lib/secret_spec/native.ex index a0bf7ea0..dce3e5f5 100644 --- a/secretspec-ex/lib/secret_spec/native.ex +++ b/secretspec-ex/lib/secret_spec/native.ex @@ -3,9 +3,9 @@ defmodule SecretSpec.Native do version = Mix.Project.config()[:version] - local_checkout? = - File.exists?(Path.expand("../../native/secretspec_native/Cargo.toml", __DIR__)) - + # Prebuilt consumers (Nix) provide the compiled NIF at + # priv/native/secretspec_native.so and skip both the cargo build and the + # precompiled-artifact download, which are unavailable in a build sandbox. use RustlerPrecompiled, otp_app: :secretspec, crate: "secretspec_native", @@ -16,7 +16,7 @@ defmodule SecretSpec.Native do "x86_64-pc-windows-msvc" ], base_url: "https://github.com/cachix/secretspec/releases/download/v#{version}", - force_build: local_checkout?, + force_build: System.get_env("SECRETSPEC_EX_FORCE_BUILD") in ~w[1 true], version: version def resolve(_request) do From 510dc484f454e1daa2ac2cd58a720b6b98b9d5dc Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Tue, 22 Sep 2026 12:21:18 +0200 Subject: [PATCH 08/20] Complete Elixir IPC v1 SDK migration --- .github/workflows/elixir-package.yml | 195 +------ CHANGELOG.md | 5 +- Cargo.lock | 48 +- Cargo.toml | 1 - devenv.nix | 2 - docs/src/content/docs/development/sdks.mdx | 5 +- docs/src/content/docs/sdk/elixir.mdx | 76 ++- docs/src/content/docs/sdk/overview.mdx | 6 +- secretspec-ex/README.md | 34 +- secretspec-ex/config/config.exs | 4 - secretspec-ex/lib/secret_spec.ex | 158 +----- secretspec-ex/lib/secret_spec/application.ex | 9 + secretspec-ex/lib/secret_spec/builder.ex | 113 ----- .../lib/secret_spec/caller_context.ex | 13 - secretspec-ex/lib/secret_spec/codec.ex | 137 +++++ secretspec-ex/lib/secret_spec/error.ex | 14 +- .../lib/secret_spec/missing_required_error.ex | 10 - secretspec-ex/lib/secret_spec/native.ex | 35 -- secretspec-ex/lib/secret_spec/report.ex | 5 - secretspec-ex/lib/secret_spec/resolved.ex | 42 -- .../lib/secret_spec/resolved_secret.ex | 14 - secretspec-ex/lib/secret_spec/secret.ex | 15 + .../lib/secret_spec/secret_report.ex | 5 - secretspec-ex/lib/secret_spec/session.ex | 478 ++++++++++++++++++ secretspec-ex/lib/secret_spec/subprocess.ex | 138 ----- secretspec-ex/mix.exs | 13 +- secretspec-ex/mix.lock | 3 - .../native/secretspec_native/Cargo.toml | 11 - .../native/secretspec_native/src/lib.rs | 16 - secretspec-ex/test/conformance_test.exs | 100 ---- secretspec-ex/test/secret_spec_test.exs | 376 +++----------- secretspec-ex/test/subprocess_test.exs | 121 ----- secretspec/README.md | 2 +- 33 files changed, 808 insertions(+), 1396 deletions(-) create mode 100644 secretspec-ex/lib/secret_spec/application.ex delete mode 100644 secretspec-ex/lib/secret_spec/builder.ex delete mode 100644 secretspec-ex/lib/secret_spec/caller_context.ex create mode 100644 secretspec-ex/lib/secret_spec/codec.ex delete mode 100644 secretspec-ex/lib/secret_spec/missing_required_error.ex delete mode 100644 secretspec-ex/lib/secret_spec/native.ex delete mode 100644 secretspec-ex/lib/secret_spec/report.ex delete mode 100644 secretspec-ex/lib/secret_spec/resolved.ex delete mode 100644 secretspec-ex/lib/secret_spec/resolved_secret.ex create mode 100644 secretspec-ex/lib/secret_spec/secret.ex delete mode 100644 secretspec-ex/lib/secret_spec/secret_report.ex create mode 100644 secretspec-ex/lib/secret_spec/session.ex delete mode 100644 secretspec-ex/lib/secret_spec/subprocess.ex delete mode 100644 secretspec-ex/native/secretspec_native/Cargo.toml delete mode 100644 secretspec-ex/native/secretspec_native/src/lib.rs delete mode 100644 secretspec-ex/test/conformance_test.exs delete mode 100644 secretspec-ex/test/subprocess_test.exs diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml index cd7c36c8..877ca5c2 100644 --- a/.github/workflows/elixir-package.yml +++ b/.github/workflows/elixir-package.yml @@ -1,48 +1,27 @@ -name: "Elixir SDK" - -# Builds the Rustler NIF for the Elixir SDK on each supported target, publishes -# the NIF archives to the GitHub Release, and publishes the Hex package with the -# generated Rustler checksum file. +name: Elixir SDK on: + pull_request: + paths: + - "secretspec-ex/**" + - ".github/workflows/elixir-package.yml" + push: + tags: ["v**"] workflow_call: inputs: pre_release: - description: Run as part of the canonical pre-release suite required: false type: boolean default: false - release_tag: - description: Existing GitHub Release tag to upload artifacts to - required: false - type: string - default: "" - workflow_dispatch: - inputs: - release_tag: - description: Existing GitHub Release tag to upload artifacts to - required: false - type: string - default: "" - push: - tags: - - v** - pull_request: - paths: - - "secretspec-ex/**" - - ".github/workflows/elixir-package.yml" - - "scripts/install-rustup.sh" - - "scripts/sync-sdk-versions.sh" permissions: contents: read jobs: - test: - name: Elixir tests + package: + name: Build and test Elixir package if: >- - inputs.pre_release || - github.event_name != 'pull_request' || + inputs.pre_release || github.event_name != 'pull_request' || !startsWith(github.event.pull_request.title, 'Release ') runs-on: ubuntu-latest steps: @@ -53,156 +32,28 @@ jobs: 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: Fetch dependencies + - name: Build SecretSpec IPC executable + run: cargo build --release --features ipc + - 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 - - nif: - name: NIF ${{ matrix.target }} - if: >- - inputs.pre_release || - github.event_name != 'pull_request' || - !startsWith(github.event.pull_request.title, 'Release ') - needs: [test] - runs-on: ${{ matrix.runner }} - container: ${{ matrix.container || null }} - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - runner: ubuntu-latest - container: quay.io/pypa/manylinux_2_28_x86_64:2026.08.05-1@sha256:e0b40ace8e818e96026eb47714b01998cbca022a6995797d0905474ce3e82ae8 - - target: aarch64-unknown-linux-gnu - runner: ubuntu-24.04-arm - container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.05-1@sha256:f766b402889e40f439e7a3ee5788eef1aa3ef399d0110107d27419fa2ba9d905 - - { target: aarch64-apple-darwin, runner: macos-latest } - - { target: x86_64-pc-windows-msvc, runner: windows-latest } - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - submodules: recursive - - name: Install verified rustup (the manylinux container ships none) - if: matrix.container - shell: bash - run: | - bash scripts/install-rustup.sh - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Install Rust (pinned by rust-toolchain.toml) - run: rustup toolchain install - - name: Sync SDK package versions - run: bash scripts/sync-sdk-versions.sh - - name: Build Rustler NIF - id: build - uses: philss/rustler-precompiled-action@v1.1.5 - env: - CARGO_TARGET_DIR: target - with: - project-name: secretspec_native - project-version: ${{ env.PROJECT_VERSION }} - target: ${{ matrix.target }} - nif-version: "2.15" - project-dir: secretspec-ex/native/secretspec_native - - name: Verify Linux portability (glibc <= 2.28, no libdbus) - if: matrix.container - shell: bash - run: >- - bash scripts/check-linux-portability.sh - secretspec-ex/native/secretspec_native/target/${{ matrix.target }}/release/libsecretspec_native.so - - name: Verify generated archive - shell: bash - run: test -s "${{ steps.build.outputs.file-path }}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: elixir-nif-${{ matrix.target }} - path: ${{ steps.build.outputs.file-path }} - - publish-nifs: - name: Publish Elixir NIFs - needs: [nif] - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || - inputs.release_tag != '' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: elixir-nif-* - path: artifacts - merge-multiple: true - - name: Attach NIF archives to the GitHub Release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} - run: | - set -euo pipefail - mapfile -t assets < <(find artifacts -type f -name '*secretspec_native-*.tar.gz' -print | sort) - if [[ "${#assets[@]}" -ne 4 ]]; then - printf 'expected 4 Elixir NIF archives, found %s\n' "${#assets[@]}" >&2 - printf '%s\n' "${assets[@]}" >&2 - exit 1 - fi - bash scripts/upload-release-asset.sh "$RELEASE_TAG" "${assets[@]}" - - package: - name: Build and publish Elixir package - needs: [test, publish-nifs] - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || - inputs.release_tag != '' - runs-on: ubuntu-latest - permissions: - contents: read - environment: - name: hex - url: https://hex.pm/packages/secretspec - 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 Hex and Rebar - run: | - mix local.hex --force - mix local.rebar --force - - name: Sync SDK package versions - run: bash scripts/sync-sdk-versions.sh - - name: Fetch dependencies - working-directory: secretspec-ex - run: mix deps.get - - name: Generate Rustler checksums from release assets + - name: Build docs working-directory: secretspec-ex - run: mix rustler_precompiled.download SecretSpec.Native --all --print - - name: Verify Rustler checksum file - working-directory: secretspec-ex - run: test -s checksum-Elixir.SecretSpec.Native.exs - - name: Build Hex package + run: mix docs + - name: Validate package working-directory: secretspec-ex run: mix hex.build - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: elixir-package - path: secretspec-ex/secretspec-*.tar - - name: Publish package to Hex.pm - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - working-directory: secretspec-ex - env: - HEX_API_KEY: ${{ secrets.HEX_API_KEY }} - run: mix hex.publish --yes diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ac6f59..e5161990 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,9 +238,7 @@ 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`) resolves SecretSpec declarations from - Elixir through a Rustler binding, with precompiled NIF archives for supported - Linux, macOS, and Windows targets. +- **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 diff --git a/Cargo.lock b/Cargo.lock index 2b6f61a0..b2047550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1428,7 +1428,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -4095,16 +4095,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libm" version = "0.2.16" @@ -4361,7 +4351,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] @@ -5728,32 +5718,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustler" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60ac8495cb6091c1f8bd0c3bb816cd4e6f0a073f396d77eb602cb7f0615212de" -dependencies = [ - "inventory", - "libc", - "libloading 0.9.0", - "regex-lite", - "rustler_codegen", -] - -[[package]] -name = "rustler_codegen" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780a2a6b7e3cfac35820473f85cec1cd84d1bdd37ebcb4e24fb126ef0f074838" -dependencies = [ - "heck", - "inventory", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "rustls" version = "0.21.12" @@ -6163,14 +6127,6 @@ dependencies = [ "secretspec", ] -[[package]] -name = "secretspec_native" -version = "0.20.0" -dependencies = [ - "rustler", - "secretspec", -] - [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index b3535392..02df4534 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ "secretspec-node", "secretspec-php", "secretspec-py", - "secretspec-ex/native/secretspec_native", ] resolver = "2" diff --git a/devenv.nix b/devenv.nix index 774700ca..c1e92215 100644 --- a/devenv.nix +++ b/devenv.nix @@ -115,8 +115,6 @@ env = { # Gradle runs on JDK 21 but compiles the JVM SDK against this JDK 11 toolchain. SECRETSPEC_JVM_TARGET_JDK = "${pkgs.jdk11}"; - # Force-build the development version of the Elixir SDK - SECRETSPEC_EX_FORCE_BUILD = true; } // # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). # Keep these Linux-only: interpolating the cross-toolchain paths on macOS makes diff --git a/docs/src/content/docs/development/sdks.mdx b/docs/src/content/docs/development/sdks.mdx index 074bdfb1..9599b34b 100644 --- a/docs/src/content/docs/development/sdks.mdx +++ b/docs/src/content/docs/development/sdks.mdx @@ -73,12 +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 + Rustler NIF release assets | `elixir-package.yml` | +| Elixir (0.21+) | `secretspec` on Hex.pm, using `secretspec serve` | `elixir-package.yml` | -The Elixir SDK is scheduled for SecretSpec 0.21. Its package and workflow are -available for integration testing while that release is prepared. +The Elixir SDK is available from SecretSpec 0.21 and uses the shared resolver IPC protocol. ## Platform support diff --git a/docs/src/content/docs/sdk/elixir.mdx b/docs/src/content/docs/sdk/elixir.mdx index 24c00b49..57498936 100644 --- a/docs/src/content/docs/sdk/elixir.mdx +++ b/docs/src/content/docs/sdk/elixir.mdx @@ -7,13 +7,9 @@ import VersionCompatibility from '@cachix/site-kit/ui/VersionCompatibility.astro -The Elixir SDK (`secretspec`) is a thin [Rustler](https://hex.pm/packages/rustler) binding over the shared SecretSpec Rust resolver. Providers, profiles, fallback chains, generated values, and materialized paths remain in the Rust core. +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 {/* #install-021 */} - - - -Add the package to `mix.exs`: +## Install ```elixir defp deps do @@ -21,50 +17,50 @@ defp deps do end ``` -The package includes precompiled NIFs for Linux x64 and arm64, macOS Apple silicon, and Windows x64. It uses RustlerPrecompiled to select the matching native archive. +Install SecretSpec separately and make `secretspec` available on `PATH`, or pass an explicit absolute `executable:` option. + +## Sessions -## Quick start +A session fixes the manifest, profile, scope, provider override, and reason for its lifetime. Resolution addresses one exact declared name at a time. ```elixir -resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_provider("dotenv://.env") - |> SecretSpec.Builder.with_reason("start application") - |> SecretSpec.Builder.load() - -url = resolved.secrets["DATABASE_URL"] |> SecretSpec.ResolvedSecret.get() -SecretSpec.Resolved.set_as_env(resolved) -SecretSpec.Resolved.close(resolved) +{: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) ``` -`SecretSpec.resolve/1` provides the same operation for one-shot calls: +`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. -```elixir -resolved = SecretSpec.resolve(provider: "keyring://", reason: "boot") -``` +`purpose` must include non-empty `consumer` and `operation` fields. Optional `host` and `path` fields provide additional attribution. -A missing required secret raises `SecretSpec.MissingRequiredError`. Other resolver failures raise `SecretSpec.Error` with a stable `kind`. +## Results -## Reports +- `{:ok, %SecretSpec.Secret{}}` +- `{:missing, required}` +- `:undeclared` +- `{:error, %SecretSpec.Error{}}` -`report/1` performs a value-free preflight and returns status and provenance metadata: +Use `SecretSpec.with_session/2` for scoped lifetimes: ```elixir -report = SecretSpec.report(path: "secretspec.toml", provider: "dotenv://.env") +SecretSpec.with_session([manifest: "/project/secretspec.toml"], fn session -> + SecretSpec.Session.get(session, "DATABASE_URL", purpose: %{consumer: "app", operation: "connect"}) +end) ``` -## Scopes - -Use `with_scope/2` to resolve a named `[scopes.]` subset. The selected name is available as `resolved.scope` and `report.scope`. - -## Inline specifications - -Use `with_inline_spec/3` when the declaration lives in application code. The base directory controls relative provider paths, while the Rust core validates the strict inline-spec v2 (0.21+) envelope. - -## Lifetime and file secrets - -Secrets with `as_path = true` are materialized as mode-0400 temporary files. Call `SecretSpec.Resolved.close/1` when the resolved value is no longer needed so those files are removed. - -## Native extension - -The release package carries precompiled NIF archives for its supported targets. +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 439f515b..28cc5a57 100644 --- a/docs/src/content/docs/sdk/overview.mdx +++ b/docs/src/content/docs/sdk/overview.mdx @@ -43,8 +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 Rustler NIF and RustlerPrecompiled archives to call - the shared resolver directly from the BEAM. +- **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 @@ -134,8 +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+)** ships RustlerPrecompiled NIF archives for Linux x64/Arm64, - macOS Apple silicon, and Windows x64. +- **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` diff --git a/secretspec-ex/README.md b/secretspec-ex/README.md index 649293a7..a6c9b623 100644 --- a/secretspec-ex/README.md +++ b/secretspec-ex/README.md @@ -1,21 +1,27 @@ # SecretSpec Elixir SDK -The Elixir SDK (0.21+) is a thin Rustler binding over the shared SecretSpec Rust -resolver. The Elixir layer builds the JSON request and exposes the response as -small structs; providers, profiles, fallback chains, and materialized paths -remain in Rust. +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 -resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_provider("dotenv://.env") - |> SecretSpec.Builder.with_reason("start application") - |> SecretSpec.Builder.load() +{:ok, session} = + SecretSpec.Session.start_link( + manifest: "/project/secretspec.toml", + profile: "production", + scope: "deploy", + reason: "start application" + ) -resolved.secrets["DATABASE_URL"] |> SecretSpec.ResolvedSecret.get() -SecretSpec.Resolved.set_as_env(resolved) -SecretSpec.Resolved.close(resolved) +{:ok, secret} = + SecretSpec.Session.get(session, "DATABASE_URL", + representation: :value, + purpose: %{consumer: "my_app", operation: "connect", host: "db.example.com"} + ) + +SecretSpec.Session.close(session) ``` -Build and test a checkout with `mix test`. A Rust toolchain and a C compiler -are required. +`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 index f93fdeb4..becde769 100644 --- a/secretspec-ex/config/config.exs +++ b/secretspec-ex/config/config.exs @@ -1,5 +1 @@ import Config - -config :secretspec, SecretSpec.Native, - crate: "secretspec_native", - mode: :release diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex index f8766d3a..92a5ae19 100644 --- a/secretspec-ex/lib/secret_spec.ex +++ b/secretspec-ex/lib/secret_spec.ex @@ -1,157 +1,15 @@ defmodule SecretSpec do - @moduledoc """ - Elixir SDK for SecretSpec. + @moduledoc "Pure Elixir client for the SecretSpec resolver IPC protocol." - The Rustler native module calls the shared Rust resolver directly. This layer - builds requests and maps the JSON response into Elixir structs. - """ + alias SecretSpec.Session - alias SecretSpec.{ - Builder, - Error, - MissingRequiredError, - Native, - Report, - Resolved, - ResolvedSecret, - SecretReport, - Subprocess - } - - @doc "Starts a fluent SecretSpec builder." - def builder, do: Builder.new() - - @doc "Resolves secrets using the supplied options." - def resolve(opts \\ []) do - builder() - |> Builder.configure(opts) - |> Builder.load() - end - - @doc "Returns a value-free resolution report." - def report(opts \\ []) do - builder() - |> Builder.configure(opts) - |> Builder.report() - end - - @doc "Returns the native bridge version." - def abi_version, do: Native.abi_version() - - @doc false - def checked_response({request, versioned}, kind, expected_version) do - request_json = Jason.encode!(request) - - # CLI-backed providers (LastPass, 1Password, …) waitpid, which fails while - # OTP ignores SIGCHLD. The flip is VM-wide, so engage it only for requests - # that may reach such a provider; Subprocess stays fail-safe. - native_call = fn -> - if versioned, do: Native.call(request_json), else: Native.resolve(request_json) - end - - raw = - if Subprocess.engaged_by?(request) do - with_default_sigchld(native_call) - else - native_call.() + def with_session(options, fun) when is_function(fun, 1) do + with {:ok, session} <- Session.start_link(options) do + try do + fun.(session) + after + Session.close(session) end - - raw - |> Jason.decode!() - |> checked_envelope(kind, expected_version) - end - - @doc false - def checked_envelope(envelope, kind, expected_version) do - case envelope do - %{"ok" => true, "response" => %{"schema_version" => ^expected_version} = response} -> - response - - %{"ok" => true, "response" => response} when is_map(response) -> - raise %Error{ - kind: "version", - message: - "unsupported #{kind} schema version #{response["schema_version"]} (expected #{expected_version})" - } - - %{"ok" => true} -> - raise %Error{kind: "ffi", message: "secretspec_#{kind} reported ok with no response"} - - %{"ok" => false, "error" => %{"kind" => error_kind, "message" => message}} -> - raise %Error{kind: error_kind, message: message} - - _ -> - raise %Error{kind: "ffi", message: "invalid secretspec_#{kind} response envelope"} - end - end - - defp with_default_sigchld(fun) do - case :os.type() do - {:unix, _} -> - :global.trans({__MODULE__, self()}, fn -> - :ok = :os.set_signal(:sigchld, :default) - - try do - fun.() - after - # OTP normally starts with SIGCHLD ignored, but the public Erlang API has no - # getter, so restore that assumed original disposition. - :ok = :os.set_signal(:sigchld, :ignore) - end - end) - - {:win32, _} -> - fun.() end end - - def to_resolved(%{"missing_required" => [_ | _] = missing}) do - raise %MissingRequiredError{missing: missing} - end - - def to_resolved(response) do - secrets = - Map.new(response["secrets"] || %{}, fn {name, entry} -> - {name, - %ResolvedSecret{ - value: entry["value"], - path: entry["path"], - as_path: entry["as_path"], - source: entry["source"], - source_provider: entry["source_provider"] - }} - end) - - %Resolved{ - provider: response["provider"], - profile: response["profile"], - secrets: secrets, - missing_optional: response["missing_optional"] || [], - scope: response["scope"] - } - end - - @doc false - def to_report(response) do - secrets = - Enum.map(response["secrets"] || [], fn entry -> - %SecretReport{ - name: entry["name"], - status: entry["status"], - required: entry["required"], - source_provider: entry["source_provider"], - default_applied: entry["default_applied"], - generated: entry["generated"], - as_path: entry["as_path"] - } - end) - - %Report{ - provider: response["provider"], - profile: response["profile"], - secrets: secrets, - scope: response["scope"], - constraint_violations: response["constraint_violations"] || [] - } - 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 00000000..80f0c792 --- /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/builder.ex b/secretspec-ex/lib/secret_spec/builder.ex deleted file mode 100644 index 4bd9d939..00000000 --- a/secretspec-ex/lib/secret_spec/builder.ex +++ /dev/null @@ -1,113 +0,0 @@ -defmodule SecretSpec.Builder do - @moduledoc "Fluent request builder for SecretSpec resolution." - - alias SecretSpec.CallerContext - defstruct request: %{}, inline: nil - - @doc "Creates an empty builder." - def new, do: %__MODULE__{} - - @doc "Sets the manifest path." - def with_path(%__MODULE__{} = builder, path) do - %{builder | request: Map.put(builder.request, "path", path), inline: nil} - end - - @doc "Sets the provider URI or name." - def with_provider(%__MODULE__{} = builder, provider) do - put(builder, "provider", provider) - end - - @doc "Sets the profile." - def with_profile(%__MODULE__{} = builder, profile) do - put(builder, "profile", profile) - end - - @doc "Sets the manifest scope." - def with_scope(%__MODULE__{} = builder, scope) do - put(builder, "scope", scope) - end - - @doc "Sets the audit reason." - def with_reason(%__MODULE__{} = builder, reason) do - put(builder, "reason", reason) - end - - @doc "Sets caller audit context." - def with_caller(%__MODULE__{} = builder, caller) do - put(builder, "caller", CallerContext.to_request(caller)) - end - - @doc "Controls whether values are omitted." - def with_no_values(%__MODULE__{} = builder, value \\ true) do - put(builder, "no_values", value) - end - - @doc "Sets an inline specification and its logical base directory." - def with_inline_spec(%__MODULE__{} = builder, spec, base_dir) do - %{builder | request: Map.delete(builder.request, "path"), inline: {spec, base_dir}} - end - - @doc "Applies one-shot options to a builder." - def configure(builder, opts) do - Enum.reduce(opts, builder, fn - {:path, value}, builder -> with_path(builder, value) - {:provider, value}, builder -> with_provider(builder, value) - {:profile, value}, builder -> with_profile(builder, value) - {:scope, value}, builder -> with_scope(builder, value) - {:reason, value}, builder -> with_reason(builder, value) - {:caller, value}, builder -> with_caller(builder, value) - {:no_values, value}, builder -> with_no_values(builder, value) - end) - end - - @doc "Resolves and returns a `SecretSpec.Resolved`." - def load(%__MODULE__{} = builder) do - builder - |> native_request() - |> SecretSpec.checked_response("resolve", 2) - |> SecretSpec.to_resolved() - end - - @doc "Resolves without returning secret values." - def report(%__MODULE__{} = builder) do - builder - |> native_request("report") - |> SecretSpec.checked_response("report", 1) - |> SecretSpec.to_report() - end - - defp put(builder, key, value) do - %{builder | request: Map.put(builder.request, key, value)} - end - - defp native_request(%__MODULE__{request: request, inline: nil}) do - {request, false} - end - - defp native_request(%__MODULE__{request: request, inline: {spec, base_dir}}) do - {%{ - "request_version" => 1, - "operation" => "resolve", - "source" => %{ - "kind" => "inline", - "spec_version" => 2, - "base_dir" => base_dir, - "spec" => spec - }, - "options" => request - }, true} - end - - defp native_request(builder, mode) do - {request, versioned} = native_request(builder) - {put_mode(request, mode, versioned), versioned} - end - - defp put_mode(%{"options" => options} = request, mode, true) do - %{request | "options" => Map.put(options, "mode", mode)} - end - - defp put_mode(request, mode, false) do - Map.put(request, "mode", mode) - end -end diff --git a/secretspec-ex/lib/secret_spec/caller_context.ex b/secretspec-ex/lib/secret_spec/caller_context.ex deleted file mode 100644 index 6c85177a..00000000 --- a/secretspec-ex/lib/secret_spec/caller_context.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule SecretSpec.CallerContext do - @moduledoc "Caller information sent to SecretSpec's audit policy." - - defstruct [:name, :version, :operation, :resource] - - @doc "Converts the caller context to the native request shape." - def to_request(%__MODULE__{} = caller) do - caller - |> Map.from_struct() - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> Map.new(fn {key, value} -> {Atom.to_string(key), value} end) - 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 00000000..3329fd19 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/codec.ex @@ -0,0 +1,137 @@ +defmodule SecretSpec.Codec do + @moduledoc false + @absolute_max 8 * 1024 * 1024 + @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(<>, max_frame_bytes) when byte_size(body) == size do + 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 <- duplicate_and_nesting_check(body), + {:ok, value} <- JSON.decode(body) do + {:ok, value} + else + {:error, reason} when reason in [:duplicate_key, :nesting_too_deep] -> {:error, reason} + _ -> {:error, :invalid_json} + end + end + end + + def decode(_, _), do: {:error, :truncated_frame} + def max_frame_bytes, do: @absolute_max + + 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 index 0426e361..dba8baca 100644 --- a/secretspec-ex/lib/secret_spec/error.ex +++ b/secretspec-ex/lib/secret_spec/error.ex @@ -1,10 +1,12 @@ defmodule SecretSpec.Error do - @moduledoc "A SecretSpec resolution or native bridge error." - - defexception [:kind, :message] + @moduledoc "A resolver protocol error." + defexception [:kind, :message, :data] @impl true - def message(%__MODULE__{kind: kind, message: message}) do - "#{message} (kind: #{kind})" - end + def message(%__MODULE__{kind: kind, message: message}), do: "#{message} (kind: #{kind})" + + def from_response(%{"error" => %{"kind" => kind, "message" => message} = data}), + do: %__MODULE__{kind: kind, message: message, data: data} + + def from_response(_), do: %__MODULE__{kind: "protocol", message: "malformed error response"} end diff --git a/secretspec-ex/lib/secret_spec/missing_required_error.ex b/secretspec-ex/lib/secret_spec/missing_required_error.ex deleted file mode 100644 index 905d5791..00000000 --- a/secretspec-ex/lib/secret_spec/missing_required_error.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule SecretSpec.MissingRequiredError do - @moduledoc "Raised when required secrets are missing." - - defexception [:missing] - - @impl true - def message(%__MODULE__{missing: missing}) do - "missing required secret(s): #{Enum.join(missing, ", ")}" - end -end diff --git a/secretspec-ex/lib/secret_spec/native.ex b/secretspec-ex/lib/secret_spec/native.ex deleted file mode 100644 index dce3e5f5..00000000 --- a/secretspec-ex/lib/secret_spec/native.ex +++ /dev/null @@ -1,35 +0,0 @@ -defmodule SecretSpec.Native do - @moduledoc false - - version = Mix.Project.config()[:version] - - # Prebuilt consumers (Nix) provide the compiled NIF at - # priv/native/secretspec_native.so and skip both the cargo build and the - # precompiled-artifact download, which are unavailable in a build sandbox. - use RustlerPrecompiled, - otp_app: :secretspec, - crate: "secretspec_native", - targets: [ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc" - ], - base_url: "https://github.com/cachix/secretspec/releases/download/v#{version}", - force_build: System.get_env("SECRETSPEC_EX_FORCE_BUILD") in ~w[1 true], - version: version - - def resolve(_request) do - :erlang.nif_error(:nif_not_loaded) - end - - # Rustler replaces this fallback when the loaded NIF exports `call/1`. - def call(_request) do - raise %SecretSpec.Error{ - kind: "capability", - message: "loaded libsecretspec does not support inline specs (missing secretspec_call)" - } - end - - def abi_version, do: :erlang.nif_error(:nif_not_loaded) -end diff --git a/secretspec-ex/lib/secret_spec/report.ex b/secretspec-ex/lib/secret_spec/report.ex deleted file mode 100644 index 306e76f4..00000000 --- a/secretspec-ex/lib/secret_spec/report.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule SecretSpec.Report do - @moduledoc "A value-free resolution snapshot." - - defstruct [:provider, :profile, :secrets, scope: nil, constraint_violations: []] -end diff --git a/secretspec-ex/lib/secret_spec/resolved.ex b/secretspec-ex/lib/secret_spec/resolved.ex deleted file mode 100644 index 4ca97803..00000000 --- a/secretspec-ex/lib/secret_spec/resolved.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule SecretSpec.Resolved do - @moduledoc "A successful SecretSpec resolution." - - alias SecretSpec.ResolvedSecret - defstruct [:provider, :profile, :secrets, missing_optional: [], scope: nil] - - @doc "Exports usable secrets into the Erlang VM environment." - def set_as_env(%__MODULE__{secrets: secrets}) do - Enum.each(secrets, fn {name, secret} -> - case ResolvedSecret.get(secret) do - nil -> :ok - value -> System.put_env(name, value) - end - end) - end - - @doc "Returns a flat map of secret names to usable values." - def fields(%__MODULE__{secrets: secrets}) do - Map.new(secrets, fn {name, secret} -> {name, ResolvedSecret.get(secret)} end) - end - - @doc "Removes materialized files belonging to this resolution." - def close(%__MODULE__{secrets: secrets}) do - first_error = - secrets - |> Map.values() - |> Enum.filter(&(&1.as_path && is_binary(&1.path))) - |> Enum.reduce(nil, fn secret, first_error -> - case File.rm(secret.path) do - :ok -> first_error - {:error, :enoent} -> first_error - {:error, reason} when is_nil(first_error) -> reason - {:error, _reason} -> first_error - end - end) - - case first_error do - nil -> :ok - reason -> raise File.Error, reason: reason - end - end -end diff --git a/secretspec-ex/lib/secret_spec/resolved_secret.ex b/secretspec-ex/lib/secret_spec/resolved_secret.ex deleted file mode 100644 index f4d84d70..00000000 --- a/secretspec-ex/lib/secret_spec/resolved_secret.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule SecretSpec.ResolvedSecret do - @moduledoc "One resolved secret and its provenance." - - defstruct [:value, :path, :as_path, :source, :source_provider] - - @doc "Returns the usable value or materialized file path." - def get(%__MODULE__{as_path: true, path: path}) do - path - end - - def get(%__MODULE__{value: value}) do - value - end -end diff --git a/secretspec-ex/lib/secret_spec/secret.ex b/secretspec-ex/lib/secret_spec/secret.ex new file mode 100644 index 00000000..528bb9ac --- /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/secret_report.ex b/secretspec-ex/lib/secret_spec/secret_report.ex deleted file mode 100644 index da5d5196..00000000 --- a/secretspec-ex/lib/secret_spec/secret_report.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule SecretSpec.SecretReport do - @moduledoc "Value-free outcome for one declared secret." - - defstruct [:name, :status, :required, :source_provider, :default_applied, :generated, :as_path] -end diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex new file mode 100644 index 00000000..a421f470 --- /dev/null +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -0,0 +1,478 @@ +defmodule SecretSpec.Session do + use GenServer + + alias SecretSpec.{Codec, Error, Secret} + @default_startup 5_000 + @default_timeout 30_000 + @protocol "secretspec.resolver" + @version 1 + + defstruct port: nil, + executable: nil, + methods: MapSet.new(), + limits: %{max_frame_bytes: Codec.max_frame_bytes(), max_in_flight: 1}, + next_id: 1, + pending: %{}, + callers: %{}, + waiters: [], + leases: MapSet.new(), + prompt: nil, + status: :starting, + options: [], + buffer: <<>> + + 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 + executable = Keyword.get(options, :executable) || System.find_executable("secretspec") + + if is_nil(executable) do + {:stop, {:error, :executable_not_found}} + else + args = Keyword.get(options, :arguments, ["serve"]) + + port = + Port.open({:spawn_executable, Path.expand(executable)}, [ + :binary, + :exit_status, + {:args, args} + ]) + + state = %__MODULE__{ + port: port, + executable: executable, + options: options, + prompt: Keyword.get(options, :prompt) + } + + {: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", + "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}) + {:noreply, %{state | next_id: id + 1, pending: pending}} + else + {:error, reason} -> {:stop, reason, state} + end + end + + @impl true + def handle_call({:get, name, options}, from, %{status: :starting} = state), + do: {:noreply, %{state | waiters: [{from, {:get, name, options}} | state.waiters]}} + + 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 + with {:ok, state, _} <- + request(state, "resolver.release", %{"lease_id" => lease_id}, from, @default_timeout), + do: {:noreply, state} + 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({:deadline, id}, state) do + case Map.pop(state.pending, id) do + {nil, _} -> + {:noreply, state} + + {{_kind, from, timer}, pending} -> + if timer, do: Process.cancel_timer(timer) + send_cancel(state, id) + + GenServer.reply( + from, + {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}} + ) + + {:noreply, %{state | pending: pending}} + end + end + + def handle_info({:prompt_result, id, answer}, state) do + params = + case answer do + :decline -> %{"declined" => true} + value when is_binary(value) -> %{"value" => value} + _ -> %{"declined" => true} + end + + send_message(state, %{"jsonrpc" => "2.0", "id" => id, "result" => params}) + {:noreply, state} + end + + 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}, pending} -> + if timer, do: Process.cancel_timer(timer) + send_cancel(state, id) + {:noreply, %{state | pending: pending, callers: callers}} + end + end + end + + def handle_info(_message, state), do: {:noreply, state} + + defp handle_data(data, state) when byte_size(data) < 4, do: {:noreply, %{state | buffer: data}} + + defp handle_data(<> = data, state) do + cond do + size > state.limits.max_frame_bytes -> + fail_all( + {:error, %Error{kind: "protocol", message: "frame exceeds negotiated limit"}}, + state + ) + + byte_size(rest) < size -> + {:noreply, %{state | buffer: data}} + + true -> + <> = rest + + case Codec.decode(<>, state.limits.max_frame_bytes) 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, _} -> + fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) + + {{:initialize, _from, timer}, pending} -> + if timer, do: Process.cancel_timer(timer) + + with :ok <- validate_initialize(result, state) do + state = %{ + state + | pending: pending, + 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}} -> + GenServer.cast(self(), {:retry, from, name, options}) + end) + + {:noreply, state} + else + {:error, reason} -> fail_all({:error, reason}, state) + end + + {{:request, from, timer}, pending} -> + if timer, do: Process.cancel_timer(timer) + reply = decode_result(result) + GenServer.reply(from, reply) + + if state.status == :closing do + Port.close(state.port) + {:stop, :normal, %{state | pending: pending, status: :closed}} + else + {:noreply, %{state | pending: pending}} + end + end + end + + defp handle_message(%{"id" => id, "error" => error}, state) do + case Map.pop(state.pending, id) do + {{_kind, from, timer}, pending} -> + if(timer, do: Process.cancel_timer(timer)) + GenServer.reply(from, {:error, Error.from_response(%{"error" => error})}) + {:noreply, %{state | pending: pending}} + + {nil, _} -> + fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) + end + end + + defp handle_message(%{"method" => "client.prompt", "id" => id, "params" => params}, state) + when is_function(state.prompt, 1) do + parent = self() + + Task.Supervisor.start_child(SecretSpec.TaskSupervisor, fn -> + send(parent, {:prompt_result, id, state.prompt.(params)}) + end) + + {:noreply, state} + 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}, state), + do: handle_call({:get, name, options}, from, state) + + 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 + 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}) + + {:ok, + %{ + state + | next_id: id + 1, + pending: pending, + callers: Map.put(state.callers, caller_ref, id) + }, id} + end + end + + defp send_message(state, message) do + case Codec.encode(message, state.limits.max_frame_bytes) do + {:ok, frame} -> + Port.command(state.port, frame) + :ok + + 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}} -> + if timer, do: Process.cancel_timer(timer) + + {_id, {_kind, from, timer}} -> + if timer, do: Process.cancel_timer(timer) + GenServer.reply(from, reply) + end) + + {:stop, :normal, %{state | pending: %{}}} + end + + defp initialize_application(options) do + manifest = Keyword.get(options, :manifest) + + application = %{ + "manifest" => manifest_value(manifest), + "provider" => Keyword.get(options, :provider), + "profile" => Keyword.get(options, :profile), + "scope" => Keyword.get(options, :scope), + "reason" => Keyword.get(options, :reason) + } + + Enum.reject(application, fn {_k, v} -> is_nil(v) end) |> Map.new() + 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) when is_map(limits) do + %{ + max_frame_bytes: + Map.get( + limits, + "max_frame_bytes", + Map.get(limits, :max_frame_bytes, defaults.max_frame_bytes) + ), + max_in_flight: + Map.get(limits, "max_in_flight", Map.get(limits, :max_in_flight, defaults.max_in_flight)) + } + end + + defp normalize_limits(_, defaults), do: defaults + + defp validate_initialize( + %{ + "protocol" => @protocol, + "version" => @version, + "methods" => methods, + "limits" => limits + }, + _state + ) + when is_list(methods) and is_map(limits), + do: + if(Enum.all?(methods, &is_binary/1), + do: :ok, + else: {:error, %Error{kind: "protocol", message: "invalid methods"}} + ) + + defp validate_initialize(_, _), + do: {:error, %Error{kind: "protocol", message: "invalid initialization response"}} + + defp decode_result(%{"status" => "undeclared"}), do: :undeclared + defp decode_result(%{"status" => "missing", "required" => required}), do: {:missing, required} + defp decode_result(%{"released" => true}), do: :ok + defp decode_result(%{} = result) when map_size(result) == 0, do: :ok + + defp decode_result(%{"status" => "resolved"} = result), + do: + {:ok, + struct(Secret, %{ + representation: result["representation"], + value: result["value"], + path: result["path"], + lease_id: result["lease_id"], + 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"] + })} + + defp decode_result(result), + do: {:error, %Error{kind: "protocol", message: "invalid resolver result: #{inspect(result)}"}} +end diff --git a/secretspec-ex/lib/secret_spec/subprocess.ex b/secretspec-ex/lib/secret_spec/subprocess.ex deleted file mode 100644 index df9d0290..00000000 --- a/secretspec-ex/lib/secret_spec/subprocess.ex +++ /dev/null @@ -1,138 +0,0 @@ -defmodule SecretSpec.Subprocess do - @moduledoc """ - Detects whether a native request may reach a provider that spawns a - subprocess. - - The BEAM starts with SIGCHLD ignored, which breaks Rust's `waitpid`-based - CLI providers (`lpass`, `op`, `bw`, …). The NIF bridge restores SIGCHLD - before such calls, but that flip is a VM-wide signal mutation, so it must - only run when a subprocess-backed provider is actually in play. This module - inspects the request the way the builder built it: the explicit provider - spec, the inline spec's provider aliases, per-secret provider chains, and - profile defaults. - - The scheme lists below mirror the core's provider registrations - (`secretspec/src/provider/*.rs`); a new CLI-backed registration must be - added to `@subprocess_schemes` here. - """ - - @subprocess_schemes [ - "akv", - "bw", - "bws", - "cloudflare", - "dashlane", - "ejson", - "fly", - "gopass", - "lastpass", - "onepassword", - "onepassword+token", - "op", - "pass", - "passbolt", - "protonpass", - "sops" - ] - - @in_process_schemes [ - "aac", - "age", - "awsps", - "awssm", - "cloudflarekvs", - "dotenv", - "env", - "file", - "gcsm", - "infisical", - "kdbx", - "keeper", - "keyring", - "k8s+configmap", - "k8s+secret", - "kubernetes", - "openbao", - "scaleway", - "systemd-credential", - "vault" - ] - - @doc """ - Returns true when the request may reach a subprocess-spawning provider. - - Fail-safe: any provider spec the SDK cannot prove subprocess-free (an - alias name, a bare provider name, a scheme missing from both lists, an - unknown shape) engages the wrapper. Only a URI whose scheme names a known - in-process provider skips it. - """ - @spec engaged_by?(map()) :: boolean() - def engaged_by?(request) when is_map(request) do - request - |> provider_specs() - |> Enum.any?(&subprocess_possible?/1) - end - - defp provider_specs(%{"provider" => provider}) when is_binary(provider) do - [provider] - end - - defp provider_specs(%{"source" => %{"kind" => "inline"} = source} = request) do - option = - case request do - %{"options" => %{"provider" => provider}} when is_binary(provider) -> [provider] - _ -> [] - end - - specs = alias_specs(source) ++ chain_specs(source) ++ default_specs(source) ++ option - - case specs do - [] -> [nil] - specs -> specs - end - end - - # A path-based request without an explicit provider: the core falls back to - # the user config default provider, which the SDK cannot inspect. The - # manifest's own aliases could name a subprocess provider, so stay safe. - defp provider_specs(_), do: [nil] - - defp alias_specs(%{"spec" => %{"providers" => providers}}) when is_map(providers) do - Enum.map(providers, fn {_name, spec} -> spec end) - end - - defp alias_specs(_), do: [] - - defp chain_specs(%{"spec" => %{"profiles" => profiles}}) when is_map(profiles) do - Enum.flat_map(profiles, fn - {_name, %{"secrets" => secrets}} when is_map(secrets) -> - Enum.flat_map(secrets, fn - {_name, %{"providers" => chain}} when is_list(chain) -> chain - _ -> [] - end) - - _ -> - [] - end) - end - - defp chain_specs(_), do: [] - - defp default_specs(%{"spec" => %{"defaults" => %{"providers" => providers}}}) - when is_list(providers) do - providers - end - - defp default_specs(_), do: [] - - defp subprocess_possible?(spec) when is_binary(spec) do - case URI.parse(spec).scheme do - scheme when scheme in @subprocess_schemes -> true - scheme when scheme in @in_process_schemes -> false - scheme when is_binary(scheme) -> true - nil -> true - end - end - - defp subprocess_possible?(_), do: true -end diff --git a/secretspec-ex/mix.exs b/secretspec-ex/mix.exs index 807a0e10..10eb08eb 100644 --- a/secretspec-ex/mix.exs +++ b/secretspec-ex/mix.exs @@ -6,7 +6,7 @@ defmodule SecretSpec.MixProject do def project do [ app: :secretspec, - version: "0.20.0", + version: "0.21.0", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, description: "Elixir SDK for SecretSpec", @@ -20,23 +20,18 @@ defmodule SecretSpec.MixProject do end def application do - [extra_applications: [:logger]] + [mod: {SecretSpec.Application, []}, extra_applications: [:logger]] end defp package do [ - files: ["lib", "checksum-*.exs", "mix.exs", "README.md"], + files: ["lib", "mix.exs", "README.md"], licenses: ["MIT"], links: %{"GitHub" => @source_url} ] end defp deps do - [ - {:jason, "~> 1.4"}, - {:rustler_precompiled, "~> 0.9.0"}, - {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}, - {:rustler, ">= 0.0.0", optional: true} - ] + [{:ex_doc, "~> 0.40.3", only: :dev, runtime: false}] end end diff --git a/secretspec-ex/mix.lock b/secretspec-ex/mix.lock index 2873fc4b..09fcb2cc 100644 --- a/secretspec-ex/mix.lock +++ b/secretspec-ex/mix.lock @@ -1,11 +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"}, - "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "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"}, - "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, } diff --git a/secretspec-ex/native/secretspec_native/Cargo.toml b/secretspec-ex/native/secretspec_native/Cargo.toml deleted file mode 100644 index 9d4ad053..00000000 --- a/secretspec-ex/native/secretspec_native/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "secretspec_native" -version.workspace = true -edition.workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -rustler = "0.38" -secretspec = { workspace = true } diff --git a/secretspec-ex/native/secretspec_native/src/lib.rs b/secretspec-ex/native/secretspec_native/src/lib.rs deleted file mode 100644 index e002ee2d..00000000 --- a/secretspec-ex/native/secretspec_native/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -#[rustler::nif(schedule = "DirtyIo")] -fn resolve(request_json: String) -> String { - secretspec::resolve_json(&request_json) -} - -#[rustler::nif(schedule = "DirtyIo")] -fn call(request_json: String) -> String { - secretspec::call_json(&request_json) -} - -#[rustler::nif] -fn abi_version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -rustler::init!("Elixir.SecretSpec.Native"); diff --git a/secretspec-ex/test/conformance_test.exs b/secretspec-ex/test/conformance_test.exs deleted file mode 100644 index f19071af..00000000 --- a/secretspec-ex/test/conformance_test.exs +++ /dev/null @@ -1,100 +0,0 @@ -defmodule SecretSpec.ConformanceTest do - use ExUnit.Case, async: true - - @fixtures Path.expand("../../conformance/fixtures", __DIR__) - - fixtures = - @fixtures |> File.ls!() |> Enum.filter(&File.dir?(Path.join(@fixtures, &1))) |> Enum.sort() - - for fixture <- fixtures do - @fixture fixture - - test "matches the canonical #{@fixture} fixture" do - fixture_path = Path.join(@fixtures, @fixture) - expected = fixture_path |> Path.join("expected.json") |> File.read!() |> Jason.decode!() - resolved = fixture_path |> builder() |> SecretSpec.Builder.load() - - try do - assert canonical(resolved) == expected - after - :ok = SecretSpec.Resolved.close(resolved) - end - end - - test "matches the no-values #{@fixture} fixture" do - fixture_path = Path.join(@fixtures, @fixture) - - expected = - fixture_path - |> Path.join("expected_no_values.json") - |> File.read!() - |> Jason.decode!() - - resolved = - fixture_path - |> builder() - |> SecretSpec.Builder.with_no_values() - |> SecretSpec.Builder.load() - - try do - assert SecretSpec.Resolved.fields(resolved) == expected - after - :ok = SecretSpec.Resolved.close(resolved) - end - end - - test "matches the report #{@fixture} fixture" do - fixture_path = Path.join(@fixtures, @fixture) - - expected = - fixture_path - |> Path.join("expected_report.json") - |> File.read!() - |> Jason.decode!() - - report = fixture_path |> builder() |> SecretSpec.Builder.report() - - assert canonical_report(report) == expected - end - end - - defp builder(fixture_path) do - SecretSpec.builder() - |> SecretSpec.Builder.with_path(Path.join(fixture_path, "secretspec.toml")) - |> SecretSpec.Builder.with_provider("dotenv://#{Path.join(fixture_path, ".env")}") - |> SecretSpec.Builder.with_reason("conformance") - end - - defp canonical(resolved) do - secrets = - Map.new(resolved.secrets, fn {name, secret} -> - value = if secret.as_path, do: File.read!(secret.path), else: secret.value - {name, %{"value" => value, "source" => secret.source, "as_path" => secret.as_path}} - end) - - %{ - "profile" => resolved.profile, - "secrets" => secrets, - "missing_required" => [], - "missing_optional" => Enum.sort(resolved.missing_optional) - } - end - - defp canonical_report(report) do - %{ - "profile" => report.profile, - "secrets" => - Map.new(report.secrets, fn secret -> - {secret.name, - %{ - "status" => secret.status, - "required" => secret.required, - "as_path" => secret.as_path, - "generated" => secret.generated, - "default_applied" => secret.default_applied, - "source_provider" => not is_nil(secret.source_provider) - }} - end) - } - end -end diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index 8b69cc22..cfd6e191 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -1,340 +1,86 @@ defmodule SecretSpecTest do use ExUnit.Case, async: true - test "profile selects its default value" do - manifest = """ - [project] - name = "profile-selection" - revision = "1.0" + alias SecretSpec.{Codec, Error, Secret} - [profiles.default] - API_KEY = { description = "API key", required = false, default = "default" } - - [profiles.production] - API_KEY = { description = "API key", required = false, default = "production" } - """ - - {manifest_path, provider} = project("", manifest) - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest_path) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.with_profile("production") - |> SecretSpec.Builder.load() - - assert resolved.profile == "production" - assert resolved.secrets["API_KEY"].value == "production" - end - - test "caller context omits unset fields" do - caller = %SecretSpec.CallerContext{name: "test", operation: "resolve"} - - assert SecretSpec.CallerContext.to_request(caller) == %{ - "name" => "test", - "operation" => "resolve" - } - end - - test "abi version is non-empty" do - assert SecretSpec.Native.abi_version() != "" + test "encodes and decodes framed JSON" do + {:ok, frame} = Codec.encode(%{"jsonrpc" => "2.0", "id" => 1}) + assert {:ok, %{"jsonrpc" => "2.0", "id" => 1}} = Codec.decode(frame) end - test "load returns values and provenance" do - {manifest, provider} = project("DATABASE_URL=postgres://db\n") - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.with_reason("elixir test") - |> SecretSpec.Builder.load() - - assert resolved.profile == "default" - assert resolved.secrets["DATABASE_URL"].value == "postgres://db" - assert resolved.secrets["DATABASE_URL"].source == "provider" - assert resolved.secrets["DATABASE_URL"].source_provider != nil - assert resolved.secrets["DEV_SESSION_SECRET"].value == "development-only-secret" - assert resolved.secrets["DEV_SESSION_SECRET"].source == "default" - assert resolved.missing_optional == ["SENTRY_DSN"] - refute Map.has_key?(resolved.secrets, "SENTRY_DSN") + test "rejects duplicate object keys" do + body = ~s({"jsonrpc":"2.0","jsonrpc":"2.0"}) + frame = <> + assert {:error, :duplicate_key} = Codec.decode(frame) end - test "inline spec resolves at its logical base directory" do - {manifest, _provider} = project("") - base_dir = Path.dirname(manifest) - File.write!(Path.join(base_dir, "inline.env"), "TOKEN=inline-elixir\n") - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_inline_spec(inline_spec(), base_dir) - |> SecretSpec.Builder.with_reason("inline test") - |> SecretSpec.Builder.load() - - assert resolved.secrets["TOKEN"].value == "inline-elixir" + test "rejects oversized frames" do + body = ~s({"value":"x"}) + frame = <> + assert {:error, :frame_too_large} = Codec.decode(frame, 4) end - test "inline spec returns a value-free report" do - {manifest, _provider} = project("") - base_dir = Path.dirname(manifest) - File.write!(Path.join(base_dir, "inline.env"), "TOKEN=inline-elixir\n") - - report = - SecretSpec.builder() - |> SecretSpec.Builder.with_inline_spec(inline_spec(), base_dir) - |> SecretSpec.Builder.report() - - assert %SecretSpec.Report{ - profile: "default", - secrets: [ - %SecretSpec.SecretReport{ - name: "TOKEN", - status: "resolved", - required: true - } - ] - } = report - end - - test "scope is selected and returned" do - {manifest, provider} = project("DATABASE_URL=postgres://db\nSENTRY_DSN=https://sentry\n") - - builder = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.with_scope("database") - |> SecretSpec.Builder.with_reason("scoped test") - - resolved = SecretSpec.Builder.load(builder) - report = SecretSpec.Builder.report(builder) - - assert resolved.scope == "database" - assert Map.keys(resolved.secrets) == ["DATABASE_URL"] - assert report.scope == "database" - assert Enum.map(report.secrets, & &1.name) == ["DATABASE_URL"] - end - - test "report preserves status metadata" do - {manifest, provider} = project("DATABASE_URL=postgres://db\n") - - report = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.report() - - assert report.profile == "default" - - database = Enum.find(report.secrets, &(&1.name == "DATABASE_URL")) - assert database.name == "DATABASE_URL" - assert database.status == "resolved" - assert database.required - assert database.source_provider =~ "dotenv:" - refute database.default_applied - refute database.generated - refute database.as_path - - assert Enum.find(report.secrets, &(&1.name == "DEV_SESSION_SECRET")).default_applied - assert Enum.find(report.secrets, &(&1.name == "SENTRY_DSN")).status == "missing_optional" - end - - test "report preserves constraint violations" do - manifest = """ - [project] - name = "constraint-report" - revision = "1.0" - - [profiles.default] - PASSWORD = { description = "Account password", required = { at_least_one = "account_auth" } } - ACCESS_TOKEN = { description = "Personal access token", required = { at_least_one = "account_auth" } } - """ - - {manifest_path, provider} = project("", manifest) - - report = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest_path) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.report() - - assert [ - %{ - "kind" => "at_least_one", - "group" => "account_auth", - "secrets" => ["ACCESS_TOKEN", "PASSWORD"], - "present" => [] - } - ] = report.constraint_violations - end - - test "no_values returns nil fields" do - {manifest, provider} = project("DATABASE_URL=postgres://db\n") - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.with_no_values() - |> SecretSpec.Builder.load() - - assert SecretSpec.Resolved.fields(resolved) == %{ - "DATABASE_URL" => nil, - "DEV_SESSION_SECRET" => nil - } - end - - test "missing required raises MissingRequiredError" do - {manifest, provider} = project("") - - assert_raise SecretSpec.MissingRequiredError, ~r/DATABASE_URL/, fn -> - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.load() - end - end - - test "as_path returns a readable file and close removes it" do - manifest = """ - [project] - name = "elixir-test" - revision = "1.0" - - [profiles.default] - TLS_CERT = { description = "cert", required = true, as_path = true } - """ - - {manifest_path, provider} = project("TLS_CERT=----cert----\n", manifest) - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest_path) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.load() - - secret = resolved.secrets["TLS_CERT"] - assert secret.as_path - assert secret.value == nil - assert File.read!(secret.path) == "----cert----" - - SecretSpec.Resolved.close(resolved) - refute File.exists?(secret.path) + test "requires purpose attribution" do + assert {:error, %Error{kind: "invalid_request"}} = + SecretSpec.Session.get(self(), "TOKEN") + catch + :exit, _ -> :ok end @tag :tmp_dir - test "close removes later materialized files after a removal error", %{tmp_dir: tmp_dir} do - blocked_path = Path.join(tmp_dir, "blocked") - later_path = Path.join(tmp_dir, "later") - File.mkdir!(blocked_path) - File.write!(later_path, "later") - - resolved = %SecretSpec.Resolved{ - secrets: %{ - "BLOCKED" => %SecretSpec.ResolvedSecret{as_path: true, path: blocked_path}, - "LATER" => %SecretSpec.ResolvedSecret{as_path: true, path: later_path} - } - } - - assert_raise File.Error, fn -> SecretSpec.Resolved.close(resolved) end - refute File.exists?(later_path) - end - - test "invalid manifest raises SecretSpec.Error" do - assert_raise SecretSpec.Error, fn -> - SecretSpec.builder() - |> SecretSpec.Builder.with_path("/definitely/does/not/exist/secretspec.toml") - |> SecretSpec.Builder.load() - end - end - - test "an ok envelope without a response raises an ffi error" do - error = - assert_raise SecretSpec.Error, fn -> - SecretSpec.checked_envelope(%{"ok" => true}, "resolve", 2) + test "runs initialize, get, and shutdown over a direct port", %{tmp_dir: tmp_dir} do + endpoint = Path.join(tmp_dir, "endpoint.exs") + + File.write!(endpoint, """ + defmodule FakeEndpoint do + def run do + case IO.binread(:stdio, 4) do + :eof -> :ok + <> -> + request = IO.binread(:stdio, size) |> JSON.decode!() + response = response(request) + body = JSON.encode!(response) + IO.binwrite(:stdio, <>) + run() + end end - assert error.kind == "ffi" - end - - test "an unsupported schema version raises a version error" do - error = - assert_raise SecretSpec.Error, fn -> - SecretSpec.checked_envelope( - %{"ok" => true, "response" => %{"schema_version" => 999}}, - "resolve", - 2 - ) + defp response(%{"id" => id, "method" => "rpc.initialize"}) do + %{"jsonrpc" => "2.0", "id" => id, "result" => %{ + "protocol" => "secretspec.resolver", "version" => 1, + "server" => %{"name" => "fake", "version" => "0"}, + "methods" => ["resolver.get", "resolver.release", "rpc.shutdown"], + "limits" => %{"max_frame_bytes" => 8388608, "max_in_flight" => 1}, + "application" => %{"manifest_kind" => "path"} + }} end - assert error.kind == "version" - end - - test "an invalid envelope raises an ffi error" do - error = - assert_raise SecretSpec.Error, fn -> - SecretSpec.checked_envelope(%{"unexpected" => true}, "resolve", 2) + defp response(%{"id" => id, "method" => "resolver.get"}) do + %{"jsonrpc" => "2.0", "id" => id, "result" => %{ + "status" => "resolved", "representation" => "value", "value" => "secret", + "source" => "provider", "source_provider" => "env://", + "expires_at_unix_ms" => nil, "refresh_at_unix_ms" => nil + }} end - assert error.kind == "ffi" - end - - test "set_as_env exports resolved secrets" do - {manifest, provider} = project("DATABASE_URL=postgres://db\n") - System.delete_env("DATABASE_URL") - - resolved = - SecretSpec.builder() - |> SecretSpec.Builder.with_path(manifest) - |> SecretSpec.Builder.with_provider(provider) - |> SecretSpec.Builder.load() - - SecretSpec.Resolved.set_as_env(resolved) - assert System.get_env("DATABASE_URL") == "postgres://db" - System.delete_env("DATABASE_URL") - end - - defp project(dotenv, manifest \\ manifest()) do - directory = - Path.join(System.tmp_dir!(), "secretspec-ex-#{System.unique_integer([:positive])}") - - File.mkdir_p!(directory) - on_exit(fn -> File.rm_rf(directory) end) - - manifest_path = Path.join(directory, "secretspec.toml") - env_path = Path.join(directory, ".env") - File.write!(manifest_path, manifest) - File.write!(env_path, dotenv) - {manifest_path, "dotenv://#{env_path}"} - end + defp response(%{"id" => id, "method" => "rpc.shutdown"}), do: %{"jsonrpc" => "2.0", "id" => id, "result" => %{}} + end - defp inline_spec do - %{ - "project" => %{"name" => "elixir-inline"}, - "providers" => %{"env" => "dotenv://inline.env"}, - "profiles" => %{ - "default" => %{ - "secrets" => %{ - "TOKEN" => %{"description" => "token", "providers" => ["env"]} - } - } - } - } - end + FakeEndpoint.run() + """) - defp manifest do - """ - [project] - name = "elixir-test" - revision = "1.0" + {:ok, session} = + SecretSpec.Session.start_link( + executable: System.find_executable("elixir"), + arguments: [endpoint], + manifest: Path.join(tmp_dir, "secretspec.toml") + ) - [profiles.default] - DATABASE_URL = { description = "DB", required = true } - DEV_SESSION_SECRET = { description = "Development-only session secret", required = false, default = "development-only-secret" } - SENTRY_DSN = { description = "sentry", required = false } + assert {:ok, %Secret{name: nil, value: "secret", representation: "value"}} = + SecretSpec.Session.get(session, "TOKEN", + purpose: %{consumer: "test", operation: "read"} + ) - [scopes.database] - secrets = ["DATABASE_URL"] - """ + assert :ok = SecretSpec.Session.close(session) end end diff --git a/secretspec-ex/test/subprocess_test.exs b/secretspec-ex/test/subprocess_test.exs deleted file mode 100644 index 0c6d0f45..00000000 --- a/secretspec-ex/test/subprocess_test.exs +++ /dev/null @@ -1,121 +0,0 @@ -defmodule SecretSpec.SubprocessTest do - use ExUnit.Case, async: true - - # A provider spec the gate cannot prove in-process (an alias, a bare name, - # or any non-URI string) must engage the wrapper; only a URI whose scheme - # names a known in-process provider may skip it. - - test "a URI with an in-process scheme does not engage" do - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "dotenv://.env"}) - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "keyring://"}) - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "env://"}) - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "file:///tmp/secrets"}) - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "vault://cluster"}) - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "awssm://us-east-1/name"}) - end - - test "a URI with a CLI-backed scheme engages" do - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "lastpass://Work"}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "op://Team"}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "sops://config.yaml"}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "pass://"}) - # Azure CLI credential shells out to `az` via azure_identity. - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "akv://my-vault"}) - end - - test "the single-colon shorthand carries its scheme, matching the core's split_spec" do - refute SecretSpec.Subprocess.engaged_by?(%{"provider" => "dotenv:.env"}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "sops:config.yaml"}) - end - - test "a scheme missing from both lists engages (fail-safe)" do - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "futureprovider://x"}) - end - - test "an alias or bare provider name engages" do - # Aliases resolve through manifest and user config the SDK cannot see; - # the gate stays fail-safe. - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "prod_vault"}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => "keyring"}) - end - - test "a path request without an explicit provider engages" do - # The core then consults the user config default provider, invisible to - # the SDK. - assert SecretSpec.Subprocess.engaged_by?(%{"path" => "secretspec.toml"}) - end - - test "an inline spec is judged by its own provider declarations" do - inline = fn providers -> - %{ - "request_version" => 1, - "operation" => "resolve", - "source" => %{ - "kind" => "inline", - "spec_version" => 2, - "base_dir" => "/tmp", - "spec" => providers - }, - "options" => %{} - } - end - - dotenv_only = %{ - "providers" => %{"env" => "dotenv://inline.env"}, - "profiles" => %{ - "default" => %{ - "secrets" => %{ - "TOKEN" => %{"description" => "t", "providers" => ["dotenv://inline.env"]} - } - } - } - } - - refute SecretSpec.Subprocess.engaged_by?(inline.(dotenv_only)) - - with_lastpass_alias = put_in(dotenv_only, ["providers", "vault"], "lastpass://Work") - assert SecretSpec.Subprocess.engaged_by?(inline.(with_lastpass_alias)) - - with_fallback_chain = - put_in(dotenv_only, ["profiles", "default", "secrets", "TOKEN", "providers"], [ - "env", - "op://Team" - ]) - - assert SecretSpec.Subprocess.engaged_by?(inline.(with_fallback_chain)) - - with_subprocess_default = - put_in(dotenv_only, ["defaults"], %{"providers" => ["sops://secrets.yaml"]}) - - assert SecretSpec.Subprocess.engaged_by?(inline.(with_subprocess_default)) - - # The provider can also ride in options alongside an inline spec - # (Builder.with_inline_spec |> with_provider puts it there). - with_option = - put_in(inline.(dotenv_only), ["options", "provider"], "lastpass://Work") - - assert SecretSpec.Subprocess.engaged_by?(with_option) - end - - test "an inline spec without any provider declaration engages" do - # The core then uses the user config default provider. - request = %{ - "request_version" => 1, - "operation" => "resolve", - "source" => %{ - "kind" => "inline", - "spec_version" => 2, - "base_dir" => "/tmp", - "spec" => %{"project" => %{"name" => "x"}} - }, - "options" => %{} - } - - assert SecretSpec.Subprocess.engaged_by?(request) - end - - test "an unexpected request shape engages" do - assert SecretSpec.Subprocess.engaged_by?(%{}) - assert SecretSpec.Subprocess.engaged_by?(%{"provider" => 42}) - end -end diff --git a/secretspec/README.md b/secretspec/README.md index 57f1bc6a..9dc7500b 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -321,7 +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) (Rustler binding with precompiled NIFs) +- [Elixir (0.21+)](https://secretspec.dev/sdk/elixir) (pure Elixir IPC client) ```python from secretspec import SecretSpec From de3dc087927dc8c85be5f874755089c9af76e140 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Tue, 22 Sep 2026 14:12:52 +0200 Subject: [PATCH 09/20] Use newline framing in Elixir IPC client --- secretspec-ex/lib/secret_spec/codec.ex | 20 ++++++++++++---- secretspec-ex/lib/secret_spec/session.ex | 30 ++++++++++++------------ secretspec-ex/test/secret_spec_test.exs | 12 +++++----- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/codec.ex b/secretspec-ex/lib/secret_spec/codec.ex index 3329fd19..c1d6b255 100644 --- a/secretspec-ex/lib/secret_spec/codec.ex +++ b/secretspec-ex/lib/secret_spec/codec.ex @@ -10,13 +10,26 @@ defmodule SecretSpec.Codec do if size < 2 or size > min(max_frame_bytes, @absolute_max) do {:error, :frame_too_large} else - {:ok, <>} + {:ok, <>} end end def decode(frame, max_frame_bytes \\ @absolute_max) - def decode(<>, max_frame_bytes) when byte_size(body) == size do + 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} @@ -35,9 +48,6 @@ defmodule SecretSpec.Codec do end end - def decode(_, _), do: {:error, :truncated_frame} - def max_frame_bytes, do: @absolute_max - defp duplicate_and_nesting_check(body) do with {:ok, rest, _depth} <- parse_value(skip_ws(body), 0), <<>> <- skip_ws(rest) do diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index a421f470..83e7749a 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -187,23 +187,23 @@ defmodule SecretSpec.Session do def handle_info(_message, state), do: {:noreply, state} - defp handle_data(data, state) when byte_size(data) < 4, do: {:noreply, %{state | buffer: data}} - - defp handle_data(<> = data, state) do - cond do - size > state.limits.max_frame_bytes -> - fail_all( - {:error, %Error{kind: "protocol", message: "frame exceeds negotiated limit"}}, - state - ) - - byte_size(rest) < size -> - {:noreply, %{state | buffer: data}} + defp handle_data(data, state) do + case :binary.match(data, "\n") do + :nomatch -> + if byte_size(data) > state.limits.max_frame_bytes do + fail_all( + {:error, %Error{kind: "protocol", message: "frame exceeds negotiated limit"}}, + state + ) + else + {:noreply, %{state | buffer: data}} + end - true -> - <> = rest + {offset, 1} -> + body = binary_part(data, 0, offset) + tail = binary_part(data, offset + 1, byte_size(data) - offset - 1) - case Codec.decode(<>, state.limits.max_frame_bytes) do + case Codec.decode(<>, state.limits.max_frame_bytes) do {:ok, message} -> case handle_message(message, state) do {:noreply, state} -> handle_data(tail, state) diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index cfd6e191..f0f66539 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -10,13 +10,13 @@ defmodule SecretSpecTest do test "rejects duplicate object keys" do body = ~s({"jsonrpc":"2.0","jsonrpc":"2.0"}) - frame = <> + frame = <> assert {:error, :duplicate_key} = Codec.decode(frame) end test "rejects oversized frames" do body = ~s({"value":"x"}) - frame = <> + frame = <> assert {:error, :frame_too_large} = Codec.decode(frame, 4) end @@ -34,13 +34,13 @@ defmodule SecretSpecTest do File.write!(endpoint, """ defmodule FakeEndpoint do def run do - case IO.binread(:stdio, 4) do + case IO.read(:stdio, :line) do :eof -> :ok - <> -> - request = IO.binread(:stdio, size) |> JSON.decode!() + line -> + request = line |> String.trim_trailing(<<10>>) |> JSON.decode!() response = response(request) body = JSON.encode!(response) - IO.binwrite(:stdio, <>) + IO.binwrite(:stdio, body <> <<10>>) run() end end From ec784d8e65b6b4d56936a115e267859932663ece Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Tue, 22 Sep 2026 14:54:13 +0200 Subject: [PATCH 10/20] Fix Elixir resolver IPC negotiation --- secretspec-ex/lib/secret_spec/session.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index 83e7749a..9bfe0748 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -4,13 +4,14 @@ defmodule SecretSpec.Session do alias SecretSpec.{Codec, Error, Secret} @default_startup 5_000 @default_timeout 30_000 + @default_max_frame_bytes 32 * 1024 @protocol "secretspec.resolver" @version 1 defstruct port: nil, executable: nil, methods: MapSet.new(), - limits: %{max_frame_bytes: Codec.max_frame_bytes(), max_in_flight: 1}, + limits: %{max_frame_bytes: @default_max_frame_bytes, max_in_flight: 1}, next_id: 1, pending: %{}, callers: %{}, @@ -72,6 +73,7 @@ defmodule SecretSpec.Session do "jsonrpc" => "2.0", "id" => id, "method" => "rpc.initialize", + "_meta" => %{"deadline_unix_ms" => System.system_time(:millisecond) + @default_startup}, "params" => %{ "protocol" => @protocol, "versions" => [@version], @@ -367,15 +369,13 @@ defmodule SecretSpec.Session do defp initialize_application(options) do manifest = Keyword.get(options, :manifest) - application = %{ + %{ "manifest" => manifest_value(manifest), "provider" => Keyword.get(options, :provider), "profile" => Keyword.get(options, :profile), "scope" => Keyword.get(options, :scope), "reason" => Keyword.get(options, :reason) } - - Enum.reject(application, fn {_k, v} -> is_nil(v) end) |> Map.new() end defp manifest_value(path) when is_binary(path), From d8f00bf247e0123abd7a5d3ea07e9d733891e54e Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Tue, 22 Sep 2026 16:04:26 +0200 Subject: [PATCH 11/20] Fix Elixir IPC error handling and CI --- .github/workflows/elixir-package.yml | 4 +-- .github/workflows/pre-release.yml | 1 - conformance/run.sh | 2 +- secretspec-ex/lib/secret_spec/error.ex | 4 +-- secretspec-ex/lib/secret_spec/session.ex | 39 +++++++++++++++++++----- secretspec-ex/test/secret_spec_test.exs | 12 ++++++++ 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml index 877ca5c2..07d89380 100644 --- a/.github/workflows/elixir-package.yml +++ b/.github/workflows/elixir-package.yml @@ -40,8 +40,8 @@ jobs: run: | mix local.hex --force mix local.rebar --force - - name: Build SecretSpec IPC executable - run: cargo build --release --features ipc + - 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 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 0e467ace..88942e61 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -93,7 +93,6 @@ jobs: uses: ./.github/workflows/elixir-package.yml with: pre_release: true - release_tag: "" python: name: Python wheels diff --git a/conformance/run.sh b/conformance/run.sh index 092c7566..e1f58b70 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -106,7 +106,7 @@ run_elixir() { ( mix local.hex --force mix local.rebar --force mix deps.get - mix test test/conformance_test.exs + mix test ); } run "Python" python run_python diff --git a/secretspec-ex/lib/secret_spec/error.ex b/secretspec-ex/lib/secret_spec/error.ex index dba8baca..d8b89f52 100644 --- a/secretspec-ex/lib/secret_spec/error.ex +++ b/secretspec-ex/lib/secret_spec/error.ex @@ -5,8 +5,8 @@ defmodule SecretSpec.Error do @impl true def message(%__MODULE__{kind: kind, message: message}), do: "#{message} (kind: #{kind})" - def from_response(%{"error" => %{"kind" => kind, "message" => message} = data}), - do: %__MODULE__{kind: kind, message: message, data: data} + 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/session.ex b/secretspec-ex/lib/secret_spec/session.ex index 9bfe0748..67821327 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -117,9 +117,10 @@ defmodule SecretSpec.Session do end def handle_call({:release, lease_id}, from, state) do - with {:ok, state, _} <- - request(state, "resolver.release", %{"lease_id" => lease_id}, from, @default_timeout), - do: {:noreply, state} + case request(state, "resolver.release", %{"lease_id" => lease_id}, from, @default_timeout) do + {:ok, state, _} -> {:noreply, state} + {:error, reason} -> {:reply, {:error, reason}, state} + end end def handle_call(:close, _from, %{status: :closed} = state), do: {:reply, :ok, state} @@ -148,11 +149,12 @@ defmodule SecretSpec.Session do if timer, do: Process.cancel_timer(timer) send_cancel(state, id) - GenServer.reply( + reply_if_present( from, {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}} ) + state = remove_caller(state, id) {:noreply, %{state | pending: pending}} end end @@ -255,6 +257,8 @@ defmodule SecretSpec.Session do reply = decode_result(result) GenServer.reply(from, reply) + state = remove_caller(state, id) + if state.status == :closing do Port.close(state.port) {:stop, :normal, %{state | pending: pending, status: :closed}} @@ -266,9 +270,14 @@ defmodule SecretSpec.Session do defp handle_message(%{"id" => id, "error" => error}, state) do case Map.pop(state.pending, id) do + {{:initialize, nil, timer}, pending} -> + if timer, do: Process.cancel_timer(timer) + fail_all({:error, Error.from_response(%{"error" => error})}, %{state | pending: pending}) + {{_kind, from, timer}, pending} -> if(timer, do: Process.cancel_timer(timer)) - GenServer.reply(from, {:error, Error.from_response(%{"error" => error})}) + reply_if_present(from, {:error, Error.from_response(%{"error" => error})}) + state = remove_caller(state, id) {:noreply, %{state | pending: pending}} {nil, _} -> @@ -334,6 +343,20 @@ defmodule SecretSpec.Session do end end + 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, state.limits.max_frame_bytes) do {:ok, frame} -> @@ -360,10 +383,12 @@ defmodule SecretSpec.Session do {_id, {_kind, from, timer}} -> if timer, do: Process.cancel_timer(timer) - GenServer.reply(from, reply) + reply_if_present(from, reply) end) - {:stop, :normal, %{state | pending: %{}}} + Enum.each(state.waiters, fn {from, _request} -> reply_if_present(from, reply) end) + Enum.each(state.callers, fn {ref, _id} -> Process.demonitor(ref, [:flush]) end) + {:stop, :normal, %{state | pending: %{}, callers: %{}, waiters: []}} end defp initialize_application(options) do diff --git a/secretspec-ex/test/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs index f0f66539..be73b877 100644 --- a/secretspec-ex/test/secret_spec_test.exs +++ b/secretspec-ex/test/secret_spec_test.exs @@ -8,6 +8,18 @@ defmodule SecretSpecTest do assert {:ok, %{"jsonrpc" => "2.0", "id" => 1}} = Codec.decode(frame) end + test "decodes structured RPC errors" do + response = %{ + "error" => %{ + "message" => "permission denied", + "data" => %{"kind" => "permission"} + } + } + + assert %Error{kind: "permission", message: "permission denied"} = + Error.from_response(response) + end + test "rejects duplicate object keys" do body = ~s({"jsonrpc":"2.0","jsonrpc":"2.0"}) frame = <> From 479e8d510af223bde36b806435abfb6bce51e515 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Tue, 22 Sep 2026 16:54:00 +0200 Subject: [PATCH 12/20] Fix Elixir IPC session lifecycle and workflow --- .github/workflows/elixir-package.yml | 4 +- secretspec-ex/lib/secret_spec/session.ex | 157 ++++++++++++++++++----- 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/.github/workflows/elixir-package.yml b/.github/workflows/elixir-package.yml index 07d89380..d8e30394 100644 --- a/.github/workflows/elixir-package.yml +++ b/.github/workflows/elixir-package.yml @@ -40,8 +40,8 @@ jobs: run: | mix local.hex --force mix local.rebar --force - - name: Build SecretSpec CLI executable - run: cargo build --release --bin secretspec --features cli + - 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 diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index 67821327..71e5ad82 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -20,7 +20,10 @@ defmodule SecretSpec.Session do prompt: nil, status: :starting, options: [], - buffer: <<>> + buffer: <<>>, + abandoned: MapSet.new(), + callbacks: %{}, + last_callback_id: 0 def start_link(options), do: GenServer.start_link(__MODULE__, options) @@ -117,7 +120,13 @@ defmodule SecretSpec.Session do end def handle_call({:release, lease_id}, from, state) do - case request(state, "resolver.release", %{"lease_id" => lease_id}, from, @default_timeout) 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 @@ -145,7 +154,7 @@ defmodule SecretSpec.Session do {nil, _} -> {:noreply, state} - {{_kind, from, timer}, pending} -> + {{kind, from, timer}, pending} -> if timer, do: Process.cancel_timer(timer) send_cancel(state, id) @@ -154,21 +163,31 @@ defmodule SecretSpec.Session do {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}} ) - state = remove_caller(state, id) - {:noreply, %{state | pending: pending}} + if kind == :initialize do + fail_all( + {:error, + %Error{ + kind: "deadline_exceeded", + message: "session initialization deadline exceeded" + }}, + %{state | pending: pending} + ) + else + state = remove_caller(state, id) + {:noreply, %{state | pending: pending, abandoned: MapSet.put(state.abandoned, id)}} + end end end def handle_info({:prompt_result, id, answer}, state) do - params = - case answer do - :decline -> %{"declined" => true} - value when is_binary(value) -> %{"value" => value} - _ -> %{"declined" => true} - end - - send_message(state, %{"jsonrpc" => "2.0", "id" => id, "result" => params}) - {:noreply, state} + case Map.pop(state.callbacks, id) do + {nil, _callbacks} -> + {:noreply, state} + + {_parent_id, callbacks} -> + send_message(state, prompt_response(id, answer)) + {:noreply, %{state | callbacks: callbacks}} + end end def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do @@ -226,7 +245,11 @@ defmodule SecretSpec.Session do defp handle_message(%{"id" => id, "result" => result}, state) when is_integer(id) do case Map.pop(state.pending, id) do {nil, _} -> - fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) + 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}, pending} -> if timer, do: Process.cancel_timer(timer) @@ -268,34 +291,53 @@ defmodule SecretSpec.Session do end end - defp handle_message(%{"id" => id, "error" => error}, state) do + 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}, pending} -> if timer, do: Process.cancel_timer(timer) fail_all({:error, Error.from_response(%{"error" => error})}, %{state | pending: pending}) {{_kind, from, timer}, pending} -> - if(timer, do: Process.cancel_timer(timer)) + if timer, do: Process.cancel_timer(timer) reply_if_present(from, {:error, Error.from_response(%{"error" => error})}) state = remove_caller(state, id) {:noreply, %{state | pending: pending}} - - {nil, _} -> - fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) end end - defp handle_message(%{"method" => "client.prompt", "id" => id, "params" => params}, state) - when is_function(state.prompt, 1) do - parent = self() - - Task.Supervisor.start_child(SecretSpec.TaskSupervisor, fn -> - send(parent, {:prompt_result, id, state.prompt.(params)}) - end) - - {:noreply, state} + defp handle_message( + %{"method" => "client.prompt", "id" => id, "_meta" => meta, "params" => params}, + state + ) + when is_integer(id) and is_map(meta) and is_map(params) and is_function(state.prompt, 1) do + with {:ok, parent_id} <- validate_callback(meta, id, state), + :ok <- validate_prompt_params(params), + true <- Map.has_key?(state.pending, parent_id) do + parent = self() + + Task.Supervisor.start_child(SecretSpec.TaskSupervisor, fn -> + send(parent, {:prompt_result, id, state.prompt.(params)}) + end) + + {:noreply, + %{state | callbacks: Map.put(state.callbacks, id, parent_id), last_callback_id: id}} + else + _ -> + fail_all({:error, %Error{kind: "protocol", message: "invalid callback request"}}, state) + end end + defp handle_message(%{"method" => method} = message, state) + when is_binary(method) and not is_map_key(message, "id"), + do: {:noreply, state} + defp handle_message(_, state), do: fail_all({:error, %Error{kind: "protocol", message: "invalid message"}}, state) @@ -388,7 +430,8 @@ defmodule SecretSpec.Session do Enum.each(state.waiters, fn {from, _request} -> reply_if_present(from, reply) end) Enum.each(state.callers, fn {ref, _id} -> Process.demonitor(ref, [:flush]) end) - {:stop, :normal, %{state | pending: %{}, callers: %{}, waiters: []}} + safe_close_port(state.port) + {:stop, :normal, %{state | pending: %{}, callers: %{}, waiters: [], callbacks: %{}}} end defp initialize_application(options) do @@ -480,7 +523,7 @@ defmodule SecretSpec.Session do defp decode_result(%{"status" => "undeclared"}), do: :undeclared defp decode_result(%{"status" => "missing", "required" => required}), do: {:missing, required} - defp decode_result(%{"released" => true}), do: :ok + defp decode_result(%{"released" => released}) when is_integer(released), do: :ok defp decode_result(%{} = result) when map_size(result) == 0, do: :ok defp decode_result(%{"status" => "resolved"} = result), @@ -490,7 +533,7 @@ defmodule SecretSpec.Session do representation: result["representation"], value: result["value"], path: result["path"], - lease_id: result["lease_id"], + lease_id: result["path_lease_id"] || result["lease_id"], source: result["source"], source_provider: result["source_provider"], revision: result["revision"], @@ -498,6 +541,56 @@ defmodule SecretSpec.Session do refresh_at: result["refresh_at_unix_ms"] })} + 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" => "declined", "message" => message} + } + } + + defp validate_callback( + %{"parent_request_id" => parent_id, "deadline_unix_ms" => deadline}, + id, + state + ) do + cond do + not is_integer(parent_id) or not is_integer(deadline) -> {:error, :invalid_callback} + id <= state.last_callback_id -> {:error, :invalid_callback} + deadline < System.system_time(:millisecond) -> {:error, :invalid_callback} + map_size(state.callbacks) >= state.limits.max_in_flight -> {:error, :invalid_callback} + true -> {:ok, parent_id} + end + end + + defp validate_callback(_, _, _), do: {:error, :invalid_callback} + + defp validate_prompt_params(%{"name" => name, "profile" => profile}) + when is_binary(name) and byte_size(name) > 0 and is_binary(profile) and + byte_size(profile) > 0, + do: :ok + + defp validate_prompt_params(_), do: {:error, :invalid_prompt_params} + + defp safe_close_port(port) do + if is_port(port), + do: + (try do + Port.close(port) + catch + _, _ -> :ok + end) + end + defp decode_result(result), do: {:error, %Error{kind: "protocol", message: "invalid resolver result: #{inspect(result)}"}} end From 0628512929e3de36e679744e66275eb729ee5e65 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 11:00:26 +0200 Subject: [PATCH 13/20] Harden resolver session lifecycle and deadlines --- secretspec-ex/lib/secret_spec.ex | 10 +- secretspec-ex/lib/secret_spec/session.ex | 666 +++++++++++++++++++---- 2 files changed, 567 insertions(+), 109 deletions(-) diff --git a/secretspec-ex/lib/secret_spec.ex b/secretspec-ex/lib/secret_spec.ex index 92a5ae19..89a299b7 100644 --- a/secretspec-ex/lib/secret_spec.ex +++ b/secretspec-ex/lib/secret_spec.ex @@ -4,11 +4,17 @@ defmodule SecretSpec do alias SecretSpec.Session def with_session(options, fun) when is_function(fun, 1) do - with {:ok, session} <- Session.start_link(options) do + with {:ok, session} <- Session.start(options) do try do fun.(session) after - Session.close(session) + try do + Session.close(session) + catch + :exit, {:noproc, _} -> :ok + :exit, :noproc -> :ok + :exit, _reason -> :ok + end end end end diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index 71e5ad82..a66e3703 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -4,11 +4,17 @@ defmodule SecretSpec.Session do alias SecretSpec.{Codec, Error, Secret} @default_startup 5_000 @default_timeout 30_000 + @max_request_timeout 300_000 @default_max_frame_bytes 32 * 1024 + @absolute_max_frame_bytes 1_048_576 @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: 1}, @@ -23,7 +29,11 @@ defmodule SecretSpec.Session do buffer: <<>>, abandoned: MapSet.new(), callbacks: %{}, - last_callback_id: 0 + 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) @@ -42,6 +52,7 @@ defmodule SecretSpec.Session do @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 @@ -49,18 +60,32 @@ defmodule SecretSpec.Session do else args = Keyword.get(options, :arguments, ["serve"]) + {spawn_executable, spawn_args} = spawn_command(executable, args) + port = - Port.open({:spawn_executable, Path.expand(executable)}, [ + Port.open({:spawn_executable, spawn_executable}, [ :binary, :exit_status, - {:args, args} + {: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, options: options, - prompt: Keyword.get(options, :prompt) + prompt: Keyword.get(options, :prompt), + owner_ref: owner_ref } {:ok, state, {:continue, :initialize}} @@ -93,15 +118,22 @@ defmodule SecretSpec.Session do 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}) - {:noreply, %{state | next_id: id + 1, pending: pending}} + + 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: {:noreply, %{state | waiters: [{from, {:get, name, options}} | state.waiters]}} + 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), @@ -132,6 +164,11 @@ defmodule SecretSpec.Session do end end + def handle_call(:close, _from, %{status: :starting} = state) do + safe_close_port(state.port, state.process_groups, state.process_grouped) + {:stop, :normal, :ok, %{state | status: :closed}} + end + def handle_call(:close, _from, %{status: :closed} = state), do: {:reply, :ok, state} def handle_call(:close, from, state) do @@ -149,6 +186,9 @@ defmodule SecretSpec.Session do 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, _} -> @@ -163,18 +203,31 @@ defmodule SecretSpec.Session do {:error, %Error{kind: "deadline_exceeded", message: "request deadline exceeded"}} ) - if kind == :initialize do + state = cancel_callbacks_for_parent(state, id) + + if kind == :initialize or state.status == :closing do fail_all( {:error, %Error{ kind: "deadline_exceeded", - message: "session initialization deadline exceeded" + message: + if(kind == :initialize, + do: "session initialization deadline exceeded", + else: "shutdown deadline exceeded" + ) }}, - %{state | pending: pending} + %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)} ) else state = remove_caller(state, id) - {:noreply, %{state | pending: pending, abandoned: MapSet.put(state.abandoned, id)}} + + {:noreply, + %{ + state + | pending: pending, + deadlines: Map.delete(state.deadlines, id), + abandoned: MapSet.put(state.abandoned, id) + }} end end end @@ -184,12 +237,48 @@ defmodule SecretSpec.Session do {nil, _callbacks} -> {:noreply, state} - {_parent_id, callbacks} -> - send_message(state, prompt_response(id, answer)) + {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} -> @@ -203,7 +292,16 @@ defmodule SecretSpec.Session do {{_kind, _from, timer}, pending} -> if timer, do: Process.cancel_timer(timer) send_cancel(state, id) - {:noreply, %{state | pending: pending, callers: callers}} + 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 @@ -211,9 +309,11 @@ defmodule SecretSpec.Session do 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) > state.limits.max_frame_bytes do + if byte_size(data) > frame_limit do fail_all( {:error, %Error{kind: "protocol", message: "frame exceeds negotiated limit"}}, state @@ -226,7 +326,7 @@ defmodule SecretSpec.Session do body = binary_part(data, 0, offset) tail = binary_part(data, offset + 1, byte_size(data) - offset - 1) - case Codec.decode(<>, state.limits.max_frame_bytes) do + case Codec.decode(<>, frame_limit) do {:ok, message} -> case handle_message(message, state) do {:noreply, state} -> handle_data(tail, state) @@ -258,6 +358,7 @@ defmodule SecretSpec.Session 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) @@ -266,8 +367,15 @@ defmodule SecretSpec.Session do waiters = Enum.reverse(state.waiters) state = %{state | waiters: []} - Enum.each(waiters, fn {from, {:get, name, options}} -> - GenServer.cast(self(), {:retry, from, name, options}) + 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} @@ -279,14 +387,15 @@ defmodule SecretSpec.Session do if timer, do: Process.cancel_timer(timer) reply = decode_result(result) 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 - Port.close(state.port) - {:stop, :normal, %{state | pending: pending, status: :closed}} + safe_close_port(state.port, state.process_groups, state.process_grouped) + {:stop, :normal, %{state | status: :closed}} else - {:noreply, %{state | pending: pending}} + {:noreply, state} end end end @@ -302,48 +411,111 @@ defmodule SecretSpec.Session do {{:initialize, nil, timer}, pending} -> if timer, do: Process.cancel_timer(timer) - fail_all({:error, Error.from_response(%{"error" => error})}, %{state | pending: pending}) + + fail_all( + {:error, Error.from_response(%{"error" => error})}, + %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)} + ) {{_kind, from, timer}, 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}} + {:noreply, %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)}} end end defp handle_message( - %{"method" => "client.prompt", "id" => id, "_meta" => meta, "params" => params}, + %{ + "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 is_function(state.prompt, 1) do - with {:ok, parent_id} <- validate_callback(meta, id, state), - :ok <- validate_prompt_params(params), - true <- Map.has_key?(state.pending, parent_id) do + 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() - Task.Supervisor.start_child(SecretSpec.TaskSupervisor, fn -> - send(parent, {:prompt_result, id, state.prompt.(params)}) - end) + 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), last_callback_id: id}} + {: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(%{"method" => method} = message, state) - when is_binary(method) and not is_map_key(message, "id"), - do: {:noreply, state} + 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}, state), - do: handle_call({:get, name, options}, from, state) + 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 @@ -359,32 +531,45 @@ defmodule SecretSpec.Session do end defp send_request(state, method, params, from, timeout) do - id = state.next_id - deadline = System.system_time(:millisecond) + timeout + 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 - } + 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}) + 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}) + deadlines = Map.put(state.deadlines, id, deadline) - {:ok, - %{ - state - | next_id: id + 1, - pending: pending, - callers: Map.put(state.callers, caller_ref, id) - }, id} + {: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} -> @@ -400,10 +585,14 @@ defmodule SecretSpec.Session do defp reply_if_present(from, reply), do: GenServer.reply(from, reply) defp send_message(state, message) do - case Codec.encode(message, state.limits.max_frame_bytes) do + case Codec.encode(message, frame_limit(state)) do {:ok, frame} -> - Port.command(state.port, frame) - :ok + try do + Port.command(state.port, frame) + :ok + catch + :error, :badarg -> {:error, :port_closed} + end error -> error @@ -428,10 +617,53 @@ defmodule SecretSpec.Session do reply_if_present(from, reply) end) - Enum.each(state.waiters, fn {from, _request} -> 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) - safe_close_port(state.port) - {:stop, :normal, %{state | pending: %{}, callers: %{}, waiters: [], callbacks: %{}}} + 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 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 @@ -487,21 +719,13 @@ defmodule SecretSpec.Session do defp validate_get(_, _), do: {:error, %Error{kind: "invalid_request", message: "secret name is invalid"}} - defp normalize_limits(limits, defaults) when is_map(limits) do + defp normalize_limits(limits, _defaults) do %{ - max_frame_bytes: - Map.get( - limits, - "max_frame_bytes", - Map.get(limits, :max_frame_bytes, defaults.max_frame_bytes) - ), - max_in_flight: - Map.get(limits, "max_in_flight", Map.get(limits, :max_in_flight, defaults.max_in_flight)) + max_frame_bytes: limits["max_frame_bytes"], + max_in_flight: limits["max_in_flight"] } end - defp normalize_limits(_, defaults), do: defaults - defp validate_initialize( %{ "protocol" => @protocol, @@ -509,18 +733,33 @@ defmodule SecretSpec.Session do "methods" => methods, "limits" => limits }, - _state + state ) - when is_list(methods) and is_map(limits), - do: - if(Enum.all?(methods, &is_binary/1), - do: :ok, - else: {:error, %Error{kind: "protocol", message: "invalid methods"}} - ) + 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 >= 4096 and frame_bytes <= @absolute_max_frame_bytes and + in_flight >= 1 and in_flight <= 32 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"}), do: :undeclared defp decode_result(%{"status" => "missing", "required" => required}), do: {:missing, required} defp decode_result(%{"released" => released}) when is_integer(released), do: :ok @@ -541,56 +780,269 @@ defmodule SecretSpec.Session do refresh_at: result["refresh_at_unix_ms"] })} + defp decode_result(result), + do: {:error, %Error{kind: "protocol", message: "invalid resolver result: #{inspect(result)}"}} + 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), + defp prompt_error(id, _message), do: %{ "jsonrpc" => "2.0", "id" => id, "error" => %{ "code" => -32008, "message" => "operation failed", - "data" => %{"kind" => "declined", "message" => message} + "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}, + %{"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 validate_prompt_params(_), do: {:error, :invalid_prompt_params} + + 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 + 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 - not is_integer(parent_id) or not is_integer(deadline) -> {:error, :invalid_callback} - id <= state.last_callback_id -> {:error, :invalid_callback} - deadline < System.system_time(:millisecond) -> {:error, :invalid_callback} - map_size(state.callbacks) >= state.limits.max_in_flight -> {:error, :invalid_callback} - true -> {:ok, parent_id} + groups != [] -> + {pids, groups} + + pids == [] -> + {pids, groups} + + attempts == 0 -> + {pids, groups} + + true -> + Process.sleep(25) + await_process_group(root, attempts - 1) end end - defp validate_callback(_, _, _), do: {:error, :invalid_callback} + 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 - defp validate_prompt_params(%{"name" => name, "profile" => profile}) - when is_binary(name) and byte_size(name) > 0 and is_binary(profile) and - byte_size(profile) > 0, - do: :ok + pids = + if Enum.any?(rows, fn {pid, _ppid, _pgid} -> pid == root end) do + collect_descendants(root, rows, []) + else + [] + end - defp validate_prompt_params(_), do: {:error, :invalid_prompt_params} + 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)) - defp safe_close_port(port) do - if is_port(port), - do: - (try do - Port.close(port) - catch - _, _ -> :ok - end) + {pids, groups} + else + _ -> {[root], []} + end + end + rescue + _ -> {[root], []} end - defp decode_result(result), - do: {:error, %Error{kind: "protocol", message: "invalid resolver result: #{inspect(result)}"}} + 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 From 371a65186e0dc7c28739acdc6fb96307729d3dd3 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 11:14:08 +0200 Subject: [PATCH 14/20] test: split Elixir SDK IPC coverage --- secretspec-ex/lib/secret_spec/codec.ex | 4 +- secretspec-ex/lib/secret_spec/session.ex | 54 +++++--- secretspec-ex/test/secret_spec/codec_test.exs | 69 +++++++++++ .../session_deadline_callback_test.exs | 79 ++++++++++++ .../secret_spec/session_lifecycle_test.exs | 83 +++++++++++++ .../secret_spec/session_protocol_test.exs | 115 +++++++++++++++++ secretspec-ex/test/secret_spec_test.exs | 98 --------------- secretspec-ex/test/support/fake_resolver.ex | 116 ++++++++++++++++++ secretspec-ex/test/test_helper.exs | 1 + 9 files changed, 502 insertions(+), 117 deletions(-) create mode 100644 secretspec-ex/test/secret_spec/codec_test.exs create mode 100644 secretspec-ex/test/secret_spec/session_deadline_callback_test.exs create mode 100644 secretspec-ex/test/secret_spec/session_lifecycle_test.exs create mode 100644 secretspec-ex/test/secret_spec/session_protocol_test.exs delete mode 100644 secretspec-ex/test/secret_spec_test.exs create mode 100644 secretspec-ex/test/support/fake_resolver.ex diff --git a/secretspec-ex/lib/secret_spec/codec.ex b/secretspec-ex/lib/secret_spec/codec.ex index c1d6b255..8f76f159 100644 --- a/secretspec-ex/lib/secret_spec/codec.ex +++ b/secretspec-ex/lib/secret_spec/codec.ex @@ -1,7 +1,7 @@ defmodule SecretSpec.Codec do @moduledoc false - @absolute_max 8 * 1024 * 1024 - @max_depth 64 + @absolute_max 1_048_576 + @max_depth 65 def encode(message, max_frame_bytes \\ @absolute_max) do body = JSON.encode!(message) diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index a66e3703..ee6cdc3e 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -765,23 +765,42 @@ defmodule SecretSpec.Session do defp decode_result(%{"released" => released}) when is_integer(released), do: :ok defp decode_result(%{} = result) when map_size(result) == 0, do: :ok - defp decode_result(%{"status" => "resolved"} = result), - do: - {:ok, - struct(Secret, %{ - representation: result["representation"], - value: result["value"], - path: result["path"], - lease_id: result["path_lease_id"] || result["lease_id"], - 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"] - })} - - defp decode_result(result), - do: {:error, %Error{kind: "protocol", message: "invalid resolver result: #{inspect(result)}"}} + defp decode_result( + %{"status" => "resolved", "representation" => "value", "value" => value} = result + ) + when is_binary(value) do + {:ok, resolved_secret(result, value: value)} + end + + defp decode_result( + %{"status" => "resolved", "representation" => "path", "path" => path} = result + ) + when is_binary(path) do + lease_id = result["path_lease_id"] || result["lease_id"] + + if is_binary(lease_id) do + {:ok, resolved_secret(result, path: path, lease_id: lease_id)} + else + {:error, %Error{kind: "protocol", message: "invalid resolver result: missing path lease"}} + end + end + + defp decode_result(_result), + do: {:error, %Error{kind: "protocol", message: "invalid resolver result"}} + + defp resolved_secret(result, fields) do + struct( + Secret, + Map.merge(Map.new(fields), %{ + 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}} @@ -868,6 +887,7 @@ defmodule SecretSpec.Session do case :erlang.port_info(port, :os_pid) do {:os_pid, pid} -> terminate_process(pid, groups, grouped) nil -> :ok + :undefined -> :ok end try do 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 00000000..da6897f2 --- /dev/null +++ b/secretspec-ex/test/secret_spec/codec_test.exs @@ -0,0 +1,69 @@ +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 {:ok, %{}} = Codec.decode("{}\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("[", 63) <> "0" <> String.duplicate("]", 63) + rejected = String.duplicate("[", 65) <> "0" <> String.duplicate("]", 64) + assert {:ok, _} = Codec.decode(accepted <> "\n") + assert {:error, :invalid_json} = 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 00000000..84ed4631 --- /dev/null +++ b/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs @@ -0,0 +1,79 @@ +defmodule SecretSpec.SessionDeadlineCallbackTest do + use ExUnit.Case, async: true + alias SecretSpec.{Error, Session} + alias SecretSpec.Test.FakeResolver + + @tag :tmp_dir + test "deadline sends one cancellation and permits later session cleanup", %{tmp_dir: dir} do + fake = FakeResolver.build(dir, no_response: "resolver.get") + {:ok, session} = Session.start_link(FakeResolver.session_options(fake)) + + assert {:error, %Error{kind: "deadline_exceeded"}} = + Session.get(session, "TOKEN", + timeout: 50, + purpose: %{consumer: "test", operation: "read"} + ) + + assert :ok = Session.close(session) + end + + @tag :tmp_dir + test "caller death cancels a pending request", %{tmp_dir: dir} do + fake = FakeResolver.build(dir, no_response: "resolver.get") + parent = self() + + owner = + spawn(fn -> + {:ok, session} = Session.start(FakeResolver.session_options(fake)) + send(parent, {:session, session}) + + Session.get(session, "TOKEN", + timeout: 5_000, + purpose: %{consumer: "test", operation: "read"} + ) + end) + + receive do + {:session, _} -> :ok + after + 2_000 -> flunk("session not started") + end + + Process.exit(owner, :kill) + end + + @tag :tmp_dir + test "late responses do not satisfy another request", %{tmp_dir: dir} do + fake = FakeResolver.build(dir, delay: 150) + {:ok, session} = Session.start_link(FakeResolver.session_options(fake)) + + assert {:error, %Error{kind: "deadline_exceeded"}} = + Session.get(session, "ONE", + timeout: 30, + purpose: %{consumer: "test", operation: "read"} + ) + + assert {:ok, _} = Session.get(session, "TWO", purpose: %{consumer: "test", operation: "read"}) + Session.close(session) + end + + @tag :tmp_dir + test "prompt callback is sent while a request is active", %{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, _} = + Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"}) + + assert_receive {:prompt, %{"name" => "TOKEN", "profile" => "default"}}, 1_000 + 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 00000000..4240c67a --- /dev/null +++ b/secretspec-ex/test/secret_spec/session_lifecycle_test.exs @@ -0,0 +1,83 @@ +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) + refute Process.alive?(session) + 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"}) + + refute Process.alive?(session) + 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 endpoint transcripts", %{tmp_dir: dir} do + for n <- 1..25 do + fake = FakeResolver.build(dir, id: "cycle-#{n}") + {: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 Enum.count(File.ls!(dir), &String.ends_with?(&1, ".exs")) == 25 + 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 00000000..092f5639 --- /dev/null +++ b/secretspec-ex/test/secret_spec/session_protocol_test.exs @@ -0,0 +1,115 @@ +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{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 "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/secret_spec_test.exs b/secretspec-ex/test/secret_spec_test.exs deleted file mode 100644 index be73b877..00000000 --- a/secretspec-ex/test/secret_spec_test.exs +++ /dev/null @@ -1,98 +0,0 @@ -defmodule SecretSpecTest do - use ExUnit.Case, async: true - - alias SecretSpec.{Codec, Error, Secret} - - test "encodes and decodes framed JSON" do - {:ok, frame} = Codec.encode(%{"jsonrpc" => "2.0", "id" => 1}) - assert {:ok, %{"jsonrpc" => "2.0", "id" => 1}} = Codec.decode(frame) - end - - test "decodes structured RPC errors" do - response = %{ - "error" => %{ - "message" => "permission denied", - "data" => %{"kind" => "permission"} - } - } - - assert %Error{kind: "permission", message: "permission denied"} = - Error.from_response(response) - end - - test "rejects duplicate object keys" do - body = ~s({"jsonrpc":"2.0","jsonrpc":"2.0"}) - frame = <> - assert {:error, :duplicate_key} = Codec.decode(frame) - end - - test "rejects oversized frames" do - body = ~s({"value":"x"}) - frame = <> - assert {:error, :frame_too_large} = Codec.decode(frame, 4) - end - - test "requires purpose attribution" do - assert {:error, %Error{kind: "invalid_request"}} = - SecretSpec.Session.get(self(), "TOKEN") - catch - :exit, _ -> :ok - end - - @tag :tmp_dir - test "runs initialize, get, and shutdown over a direct port", %{tmp_dir: tmp_dir} do - endpoint = Path.join(tmp_dir, "endpoint.exs") - - File.write!(endpoint, """ - defmodule FakeEndpoint do - def run do - case IO.read(:stdio, :line) do - :eof -> :ok - line -> - request = line |> String.trim_trailing(<<10>>) |> JSON.decode!() - response = response(request) - body = JSON.encode!(response) - IO.binwrite(:stdio, body <> <<10>>) - run() - end - end - - defp response(%{"id" => id, "method" => "rpc.initialize"}) do - %{"jsonrpc" => "2.0", "id" => id, "result" => %{ - "protocol" => "secretspec.resolver", "version" => 1, - "server" => %{"name" => "fake", "version" => "0"}, - "methods" => ["resolver.get", "resolver.release", "rpc.shutdown"], - "limits" => %{"max_frame_bytes" => 8388608, "max_in_flight" => 1}, - "application" => %{"manifest_kind" => "path"} - }} - end - - defp response(%{"id" => id, "method" => "resolver.get"}) do - %{"jsonrpc" => "2.0", "id" => id, "result" => %{ - "status" => "resolved", "representation" => "value", "value" => "secret", - "source" => "provider", "source_provider" => "env://", - "expires_at_unix_ms" => nil, "refresh_at_unix_ms" => nil - }} - end - - defp response(%{"id" => id, "method" => "rpc.shutdown"}), do: %{"jsonrpc" => "2.0", "id" => id, "result" => %{}} - end - - FakeEndpoint.run() - """) - - {:ok, session} = - SecretSpec.Session.start_link( - executable: System.find_executable("elixir"), - arguments: [endpoint], - manifest: Path.join(tmp_dir, "secretspec.toml") - ) - - assert {:ok, %Secret{name: nil, value: "secret", representation: "value"}} = - SecretSpec.Session.get(session, "TOKEN", - purpose: %{consumer: "test", operation: "read"} - ) - - assert :ok = SecretSpec.Session.close(session) - 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 00000000..21ff9447 --- /dev/null +++ b/secretspec-ex/test/support/fake_resolver.ex @@ -0,0 +1,116 @@ +defmodule SecretSpec.Test.FakeResolver do + @moduledoc "Small, isolated resolver endpoint used by the SDK tests." + + def build(tmp_dir, opts \\ []) do + id = opts[:id] || "#{System.unique_integer([:positive])}" + transcript = Path.join(tmp_dir, "#{id}.transcript") + endpoint = Path.join(tmp_dir, "#{id}.exs") + config = Keyword.merge([transcript: transcript, methods: ["resolver.get", "resolver.release", "rpc.shutdown"]], opts) + File.write!(endpoint, script(config)) + %{endpoint: endpoint, transcript: transcript, executable: System.find_executable("elixir"), arguments: [endpoint]} + end + + def session_options(fake, opts \\ []) do + [executable: fake.executable, arguments: fake.arguments] ++ opts + end + + 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_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)) do + :ok + else + if System.monotonic_time(:millisecond) < deadline do + Process.sleep(10) + do_wait(fake, fun, deadline) + else + {:error, :timeout} + end + 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 + response = fn request -> + id = request["id"] + method = request["method"] + params = request["params"] || %{} + case {method, config[:behavior]} 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 -> + frame = JSON.encode!(message) <> <<10>> + case config[:chunks] do + nil -> IO.binwrite(:stdio, frame) + chunks -> + remaining = + Enum.reduce(chunks, frame, fn n, remaining -> + {chunk, rest} = + if byte_size(remaining) <= n, + do: {remaining, <<>>}, + else: :erlang.split_binary(remaining, n) + + IO.binwrite(:stdio, chunk) + rest + end) + + IO.binwrite(:stdio, remaining) + end + end + loop = fn loop, buffer -> + case IO.read(:stdio, 1) do + :eof -> :ok + data -> + buffer = buffer <> data + case String.split(buffer, <<10>>, parts: 2) do + [rest] -> loop.(loop, rest) + [line, rest] -> + request = JSON.decode!(line) + write.(request) + if config[:hang] == request["method"], do: Process.sleep(:infinity) + if config[:exit] == request["method"], do: System.halt(0) + if is_integer(config[:delay]) and config[:delay] > 0, do: Process.sleep(config[:delay]) + if config[:send_callback] && request["method"] == "resolver.get" do + emit.(%{"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"}}) + end + if config[:no_response] != request["method"], do: emit.(response.(request)) + loop.(loop, rest) + end + end + end + loop.(loop, "") + """ + end +end diff --git a/secretspec-ex/test/test_helper.exs b/secretspec-ex/test/test_helper.exs index 869559e7..c2850047 100644 --- a/secretspec-ex/test/test_helper.exs +++ b/secretspec-ex/test/test_helper.exs @@ -1 +1,2 @@ ExUnit.start() +Code.require_file("support/fake_resolver.ex", __DIR__) From 158ea3166da0adebb4f2ee429e8af5ff36b49cd2 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 11:33:45 +0200 Subject: [PATCH 15/20] test: close IPC coverage gaps --- secretspec-ex/lib/secret_spec/codec.ex | 2 +- secretspec-ex/test/secret_spec/codec_test.exs | 6 +- .../session_deadline_callback_test.exs | 56 +++++++---- .../secret_spec/session_lifecycle_test.exs | 30 +++++- secretspec-ex/test/support/fake_resolver.ex | 98 +++++++++++-------- 5 files changed, 124 insertions(+), 68 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/codec.ex b/secretspec-ex/lib/secret_spec/codec.ex index 8f76f159..007aeb0a 100644 --- a/secretspec-ex/lib/secret_spec/codec.ex +++ b/secretspec-ex/lib/secret_spec/codec.ex @@ -1,7 +1,7 @@ defmodule SecretSpec.Codec do @moduledoc false @absolute_max 1_048_576 - @max_depth 65 + @max_depth 64 def encode(message, max_frame_bytes \\ @absolute_max) do body = JSON.encode!(message) diff --git a/secretspec-ex/test/secret_spec/codec_test.exs b/secretspec-ex/test/secret_spec/codec_test.exs index da6897f2..9f544ed3 100644 --- a/secretspec-ex/test/secret_spec/codec_test.exs +++ b/secretspec-ex/test/secret_spec/codec_test.exs @@ -43,10 +43,10 @@ defmodule SecretSpec.CodecTest do end test "accepts nesting depth 64 and rejects depth 65" do - accepted = String.duplicate("[", 63) <> "0" <> String.duplicate("]", 63) - rejected = String.duplicate("[", 65) <> "0" <> String.duplicate("]", 64) + accepted = String.duplicate("[", 64) <> "0" <> String.duplicate("]", 64) + rejected = String.duplicate("[", 65) <> "0" <> String.duplicate("]", 65) assert {:ok, _} = Codec.decode(accepted <> "\n") - assert {:error, :invalid_json} = Codec.decode(rejected <> "\n") + assert {:error, :nesting_too_deep} = Codec.decode(rejected <> "\n") end test "decodes structured and fallback errors" do diff --git a/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs b/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs index 84ed4631..63c1ac7a 100644 --- a/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs +++ b/secretspec-ex/test/secret_spec/session_deadline_callback_test.exs @@ -3,10 +3,14 @@ defmodule SecretSpec.SessionDeadlineCallbackTest do 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 and permits later session cleanup", %{tmp_dir: dir} do + 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", @@ -14,18 +18,26 @@ defmodule SecretSpec.SessionDeadlineCallbackTest do 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 a pending request", %{tmp_dir: dir} do + 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() - owner = + caller = spawn(fn -> - {:ok, session} = Session.start(FakeResolver.session_options(fake)) - send(parent, {:session, session}) + send(parent, {:caller, self()}) Session.get(session, "TOKEN", timeout: 5_000, @@ -33,19 +45,21 @@ defmodule SecretSpec.SessionDeadlineCallbackTest do ) end) - receive do - {:session, _} -> :ok - after - 2_000 -> flunk("session not started") - end - - Process.exit(owner, :kill) + 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 request", %{tmp_dir: dir} do + 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", @@ -53,14 +67,19 @@ defmodule SecretSpec.SessionDeadlineCallbackTest do 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"}) - Session.close(session) + assert :ok = Session.close(session) end @tag :tmp_dir - test "prompt callback is sent while a request is active", %{tmp_dir: dir} do + 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 -> @@ -69,11 +88,14 @@ defmodule SecretSpec.SessionDeadlineCallbackTest do 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 - Session.close(session) + 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 index 4240c67a..91025b54 100644 --- a/secretspec-ex/test/secret_spec/session_lifecycle_test.exs +++ b/secretspec-ex/test/secret_spec/session_lifecycle_test.exs @@ -8,7 +8,7 @@ defmodule SecretSpec.SessionLifecycleTest do fake = FakeResolver.build(dir, hang: "rpc.initialize") {:ok, session} = Session.start_link(FakeResolver.session_options(fake)) assert :ok = Session.close(session) - refute Process.alive?(session) + assert :ok = wait_until(fn -> not Process.alive?(session) end, 2_000) end @tag :tmp_dir @@ -19,7 +19,7 @@ defmodule SecretSpec.SessionLifecycleTest do assert {:error, _} = Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"}) - refute Process.alive?(session) + assert :ok = wait_until(fn -> not Process.alive?(session) end, 2_000) end @tag :tmp_dir @@ -67,9 +67,11 @@ defmodule SecretSpec.SessionLifecycleTest do @tag :os_process @tag timeout: 120_000 @tag :tmp_dir - test "repeated session cycles leave no endpoint transcripts", %{tmp_dir: dir} do + 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}") + fake = FakeResolver.build(dir, id: "cycle-#{n}", marker: marker) {:ok, session} = Session.start_link(FakeResolver.session_options(fake)) assert {:ok, _} = @@ -78,6 +80,24 @@ defmodule SecretSpec.SessionLifecycleTest do assert :ok = Session.close(session) end - assert Enum.count(File.ls!(dir), &String.ends_with?(&1, ".exs")) == 25 + 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/support/fake_resolver.ex b/secretspec-ex/test/support/fake_resolver.ex index 21ff9447..68430537 100644 --- a/secretspec-ex/test/support/fake_resolver.ex +++ b/secretspec-ex/test/support/fake_resolver.ex @@ -1,18 +1,17 @@ defmodule SecretSpec.Test.FakeResolver do - @moduledoc "Small, isolated resolver endpoint used by the SDK tests." + @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, methods: ["resolver.get", "resolver.release", "rpc.shutdown"]], opts) + config = Keyword.merge([transcript: transcript, ready: ready, methods: ["resolver.get", "resolver.release", "rpc.shutdown"]], opts) File.write!(endpoint, script(config)) - %{endpoint: endpoint, transcript: transcript, executable: System.find_executable("elixir"), arguments: [endpoint]} + %{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 - end + def session_options(fake, opts \\ []), do: [executable: fake.executable, arguments: fake.arguments] ++ opts def messages(fake) do case File.read(fake.transcript) do @@ -21,13 +20,15 @@ defmodule SecretSpec.Test.FakeResolver do 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)) do + if fun.(messages(fake)) or (fake.ready && File.exists?(fake.ready) && fun.([])) do :ok else if System.monotonic_time(:millisecond) < deadline do @@ -39,23 +40,29 @@ defmodule SecretSpec.Test.FakeResolver do 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"] - method = request["method"] - params = request["params"] || %{} - case {method, config[:behavior]} do - {"rpc.initialize", _} -> + 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", _} -> + "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}} @@ -65,52 +72,59 @@ defmodule SecretSpec.Test.FakeResolver do 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" => %{}} + "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 -> - frame = JSON.encode!(message) <> <<10>> - case config[:chunks] do - nil -> IO.binwrite(:stdio, frame) - chunks -> - remaining = - Enum.reduce(chunks, frame, fn n, remaining -> - {chunk, rest} = - if byte_size(remaining) <= n, - do: {remaining, <<>>}, - else: :erlang.split_binary(remaining, n) - - IO.binwrite(:stdio, chunk) - rest - end) - - IO.binwrite(:stdio, remaining) - 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 -> + 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) + [rest] -> loop.(loop, rest, waiting) [line, rest] -> request = JSON.decode!(line) write.(request) - if config[:hang] == request["method"], do: Process.sleep(:infinity) - if config[:exit] == request["method"], do: System.halt(0) - if is_integer(config[:delay]) and config[:delay] > 0, do: Process.sleep(config[:delay]) - if config[:send_callback] && request["method"] == "resolver.get" do - emit.(%{"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"}}) + 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), %{"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 - if config[:no_response] != request["method"], do: emit.(response.(request)) - loop.(loop, rest) end end end - loop.(loop, "") + loop.(loop, "", nil) """ end end From 736396a039cfc93fb118251afca26dc3bb0c7951 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 11:36:21 +0200 Subject: [PATCH 16/20] test: cover coalesced IPC frames --- .../test/secret_spec/session_protocol_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/secretspec-ex/test/secret_spec/session_protocol_test.exs b/secretspec-ex/test/secret_spec/session_protocol_test.exs index 092f5639..6df7996a 100644 --- a/secretspec-ex/test/secret_spec/session_protocol_test.exs +++ b/secretspec-ex/test/secret_spec/session_protocol_test.exs @@ -71,6 +71,16 @@ defmodule SecretSpec.SessionProtocolTest do assert :ok = Session.close(session) end + @tag :tmp_dir + test "handles two complete frames in one port write", %{tmp_dir: dir} do + {session, _fake} = start(dir, coalesced: true) + + assert {:ok, %Secret{value: "secret"}} = + Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"}) + + assert :ok = Session.close(session) + end + @tag :tmp_dir test "negotiated limits and methods are accepted", %{tmp_dir: dir} do init = %{ From 95ad7d6a7bac214375366734b8e1ded49510a745 Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 11:50:23 +0200 Subject: [PATCH 17/20] test: observe coalesced frame handling --- .../test/secret_spec/session_protocol_test.exs | 12 +++++------- secretspec-ex/test/support/fake_resolver.ex | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/secretspec-ex/test/secret_spec/session_protocol_test.exs b/secretspec-ex/test/secret_spec/session_protocol_test.exs index 6df7996a..f906a2f2 100644 --- a/secretspec-ex/test/secret_spec/session_protocol_test.exs +++ b/secretspec-ex/test/secret_spec/session_protocol_test.exs @@ -72,13 +72,11 @@ defmodule SecretSpec.SessionProtocolTest do end @tag :tmp_dir - test "handles two complete frames in one port write", %{tmp_dir: dir} do - {session, _fake} = start(dir, coalesced: true) - - assert {:ok, %Secret{value: "secret"}} = - Session.get(session, "TOKEN", purpose: %{consumer: "test", operation: "read"}) - - assert :ok = Session.close(session) + 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 diff --git a/secretspec-ex/test/support/fake_resolver.ex b/secretspec-ex/test/support/fake_resolver.ex index 68430537..9787ae82 100644 --- a/secretspec-ex/test/support/fake_resolver.ex +++ b/secretspec-ex/test/support/fake_resolver.ex @@ -105,7 +105,7 @@ defmodule SecretSpec.Test.FakeResolver do if request["method"] == "rpc.initialize" do File.write!(config[:ready], "ready") if config[:coalesced] do - emit_coalesced.([response.(request), %{"jsonrpc" => "2.0", "method" => "resolver.ready", "params" => %{}}]) + 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 From fb0ceda08bb3120f6b409d76e10872f1a7c77c5d Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 12:50:59 +0200 Subject: [PATCH 18/20] Upgrade Elixir to 1.20 --- devenv.nix | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/devenv.nix b/devenv.nix index c1e92215..1173df53 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 @@ -71,7 +78,7 @@ }; languages.elixir = { enable = true; - package = pkgs.beam28Packages.elixir_1_19; + package = elixir; }; packages = [ @@ -110,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 = { From 0acf1b0293d5fcd23b4031fd265c8f439c73467e Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Wed, 23 Sep 2026 12:52:09 +0200 Subject: [PATCH 19/20] Remove unused clause --- secretspec-ex/lib/secret_spec/session.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index ee6cdc3e..b9235bbb 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -870,8 +870,6 @@ defmodule SecretSpec.Session do end end - defp validate_prompt_params(_), do: {:error, :invalid_prompt_params} - defp process_groups_for_port(port) do case :erlang.port_info(port, :os_pid) do {:os_pid, root} -> From c4d2d6791d7e8189d53c3d4e8ff4433d909da6dd Mon Sep 17 00:00:00 2001 From: Sylvester Roos Date: Thu, 24 Sep 2026 15:34:52 +0200 Subject: [PATCH 20/20] Fix Elixir SDK IPC client review findings - Offer absolute-max frame size (1 MiB) and 16 in-flight requests by default, configurable via :max_frame_bytes/:max_in_flight start options, clamped to protocol ranges; validate_limits stays as the safety net - Reject literal CR anywhere in received frames per the wire contract (escaped \r in strings remains legal) - Populate Secret.name from the requested secret name - Return flat {:error, :executable_not_found} from Session.start - Reply to queued get waiters when closing a session that is still starting --- secretspec-ex/lib/secret_spec/codec.ex | 19 ++++- secretspec-ex/lib/secret_spec/session.ex | 85 +++++++++++++------ secretspec-ex/test/secret_spec/codec_test.exs | 4 +- .../secret_spec/session_protocol_test.exs | 8 +- 4 files changed, 84 insertions(+), 32 deletions(-) diff --git a/secretspec-ex/lib/secret_spec/codec.ex b/secretspec-ex/lib/secret_spec/codec.ex index 007aeb0a..6a33f0f8 100644 --- a/secretspec-ex/lib/secret_spec/codec.ex +++ b/secretspec-ex/lib/secret_spec/codec.ex @@ -38,16 +38,29 @@ defmodule SecretSpec.Codec do {:error, :invalid_utf8} true -> - with :ok <- duplicate_and_nesting_check(body), + 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] -> {:error, reason} - _ -> {:error, :invalid_json} + {: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 diff --git a/secretspec-ex/lib/secret_spec/session.ex b/secretspec-ex/lib/secret_spec/session.ex index b9235bbb..edd01704 100644 --- a/secretspec-ex/lib/secret_spec/session.ex +++ b/secretspec-ex/lib/secret_spec/session.ex @@ -5,8 +5,11 @@ defmodule SecretSpec.Session do @default_startup 5_000 @default_timeout 30_000 @max_request_timeout 300_000 - @default_max_frame_bytes 32 * 1024 @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 @@ -17,7 +20,10 @@ defmodule SecretSpec.Session do process_groups: [], executable: nil, methods: MapSet.new(), - limits: %{max_frame_bytes: @default_max_frame_bytes, max_in_flight: 1}, + limits: %{ + max_frame_bytes: @default_max_frame_bytes, + max_in_flight: @default_max_in_flight + }, next_id: 1, pending: %{}, callers: %{}, @@ -56,7 +62,7 @@ defmodule SecretSpec.Session do executable = Keyword.get(options, :executable) || System.find_executable("secretspec") if is_nil(executable) do - {:stop, {:error, :executable_not_found}} + {:stop, :executable_not_found} else args = Keyword.get(options, :arguments, ["serve"]) @@ -83,6 +89,7 @@ defmodule SecretSpec.Session do 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 @@ -117,7 +124,7 @@ defmodule SecretSpec.Session do 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}) + pending = Map.put(state.pending, id, {:initialize, nil, timer, nil}) deadlines = Map.put(state.deadlines, id, System.system_time(:millisecond) + @default_startup) @@ -166,7 +173,11 @@ defmodule SecretSpec.Session do def handle_call(:close, _from, %{status: :starting} = state) do safe_close_port(state.port, state.process_groups, state.process_grouped) - {:stop, :normal, :ok, %{state | status: :closed}} + 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} @@ -194,7 +205,7 @@ defmodule SecretSpec.Session do {nil, _} -> {:noreply, state} - {{kind, from, timer}, pending} -> + {{kind, from, timer, _name}, pending} -> if timer, do: Process.cancel_timer(timer) send_cancel(state, id) @@ -289,7 +300,7 @@ defmodule SecretSpec.Session do {nil, _pending} -> {:noreply, %{state | callers: callers}} - {{_kind, _from, timer}, pending} -> + {{_kind, _from, timer, _name}, pending} -> if timer, do: Process.cancel_timer(timer) send_cancel(state, id) state = cancel_callbacks_for_parent(state, id) @@ -351,7 +362,7 @@ defmodule SecretSpec.Session do fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) end - {{:initialize, _from, timer}, pending} -> + {{:initialize, _from, timer, _name}, pending} -> if timer, do: Process.cancel_timer(timer) with :ok <- validate_initialize(result, state) do @@ -383,9 +394,9 @@ defmodule SecretSpec.Session do {:error, reason} -> fail_all({:error, reason}, state) end - {{:request, from, timer}, pending} -> + {{:request, from, timer, name}, pending} -> if timer, do: Process.cancel_timer(timer) - reply = decode_result(result) + reply = decode_result(result, name) GenServer.reply(from, reply) state = cancel_callbacks_for_parent(state, id) state = remove_caller(state, id) @@ -409,7 +420,7 @@ defmodule SecretSpec.Session do fail_all({:error, %Error{kind: "protocol", message: "unexpected response id"}}, state) end - {{:initialize, nil, timer}, pending} -> + {{:initialize, nil, timer, _name}, pending} -> if timer, do: Process.cancel_timer(timer) fail_all( @@ -417,7 +428,7 @@ defmodule SecretSpec.Session do %{state | pending: pending, deadlines: Map.delete(state.deadlines, id)} ) - {{_kind, from, timer}, pending} -> + {{_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) @@ -546,7 +557,7 @@ defmodule SecretSpec.Session do 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}) + pending = Map.put(state.pending, id, {:request, from, timer, Map.get(params, "name")}) deadlines = Map.put(state.deadlines, id, deadline) {:ok, @@ -609,10 +620,10 @@ defmodule SecretSpec.Session do defp fail_all(reply, state) do Enum.each(state.pending, fn - {_id, {_kind, nil, timer}} -> + {_id, {_kind, nil, timer, _name}} -> if timer, do: Process.cancel_timer(timer) - {_id, {_kind, from, timer}} -> + {_id, {_kind, from, timer, _name}} -> if timer, do: Process.cancel_timer(timer) reply_if_present(from, reply) end) @@ -647,6 +658,20 @@ defmodule SecretSpec.Session do 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 @@ -753,45 +778,51 @@ defmodule SecretSpec.Session do 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 >= 4096 and frame_bytes <= @absolute_max_frame_bytes and - in_flight >= 1 and in_flight <= 32 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"}), do: :undeclared - defp decode_result(%{"status" => "missing", "required" => required}), do: {:missing, required} - defp decode_result(%{"released" => released}) when is_integer(released), do: :ok - defp decode_result(%{} = result) when map_size(result) == 0, do: :ok + 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 + %{"status" => "resolved", "representation" => "value", "value" => value} = result, + name ) when is_binary(value) do - {:ok, resolved_secret(result, value: value)} + {:ok, resolved_secret(result, name, value: value)} end defp decode_result( - %{"status" => "resolved", "representation" => "path", "path" => path} = 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, path: path, lease_id: lease_id)} + {: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), + defp decode_result(_result, _name), do: {:error, %Error{kind: "protocol", message: "invalid resolver result"}} - defp resolved_secret(result, fields) do + 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"], diff --git a/secretspec-ex/test/secret_spec/codec_test.exs b/secretspec-ex/test/secret_spec/codec_test.exs index 9f544ed3..2458065e 100644 --- a/secretspec-ex/test/secret_spec/codec_test.exs +++ b/secretspec-ex/test/secret_spec/codec_test.exs @@ -14,7 +14,9 @@ defmodule SecretSpec.CodecTest do assert {:error, :frame_too_large} = Codec.decode("\n") assert {:error, :truncated_frame} = Codec.decode("{}") assert {:error, :invalid_json} = Codec.decode("{}\n{}\n") - assert {:ok, %{}} = Codec.decode("{}\r\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 diff --git a/secretspec-ex/test/secret_spec/session_protocol_test.exs b/secretspec-ex/test/secret_spec/session_protocol_test.exs index f906a2f2..2d83e86e 100644 --- a/secretspec-ex/test/secret_spec/session_protocol_test.exs +++ b/secretspec-ex/test/secret_spec/session_protocol_test.exs @@ -31,7 +31,13 @@ defmodule SecretSpec.SessionProtocolTest do expected = case result do :path -> - {:ok, %Secret{representation: "path", path: "/tmp/secret", lease_id: "lease-1"}} + {:ok, + %Secret{ + name: "TOKEN", + representation: "path", + path: "/tmp/secret", + lease_id: "lease-1" + }} :missing -> {:missing, true}