diff --git a/.github/workflows/jvm-package.yml b/.github/workflows/jvm-package.yml
new file mode 100644
index 00000000..cba6eca4
--- /dev/null
+++ b/.github/workflows/jvm-package.yml
@@ -0,0 +1,387 @@
+name: "Java Virtual Machine SDK"
+
+# Builds the JVM client and the runtime-specific secretspec-ffi libraries packed
+# into org.cachix.secretspec-jvm. Glibc Linux uses a manylinux_2_28 baseline; Alpine
+# receives separate musl assets.
+
+on:
+ workflow_call:
+ inputs:
+ publish:
+ description: Publish the built package
+ required: false
+ type: boolean
+ default: false
+ workflow_dispatch:
+ inputs:
+ publish:
+ description: Publish the built package
+ required: false
+ type: boolean
+ default: false
+ push:
+ tags:
+ - v**
+ pull_request:
+ paths:
+ - "secretspec-jvm/**"
+ - "secretspec-ffi/**"
+ - ".github/workflows/jvm-package.yml"
+ - "scripts/install-rustup.sh"
+ - "scripts/sync-sdk-versions.sh"
+
+permissions:
+ contents: read
+
+jobs:
+ native:
+ name: ${{ matrix.rid }}
+ runs-on: ${{ matrix.runner }}
+ container: ${{ matrix.container || null }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x86-64
+ target: x86_64-unknown-linux-gnu
+ runner: ubuntu-latest
+ container: quay.io/pypa/manylinux_2_28_x86_64:2026.08.05-1@sha256:e0b40ace8e818e96026eb47714b01998cbca022a6995797d0905474ce3e82ae8
+ library: libsecretspec_ffi.so
+ rustflags: -C strip=symbols
+ - rid: linux-aarch64
+ target: aarch64-unknown-linux-gnu
+ runner: ubuntu-24.04-arm
+ container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.05-1@sha256:f766b402889e40f439e7a3ee5788eef1aa3ef399d0110107d27419fa2ba9d905
+ library: libsecretspec_ffi.so
+ rustflags: -C strip=symbols
+ - rid: darwin-x86-64
+ target: x86_64-apple-darwin
+ runner: macos-15-intel
+ library: libsecretspec_ffi.dylib
+ deployment_target: "12.0"
+ rustflags: -C strip=symbols
+ - rid: darmin-aarch64
+ target: aarch64-apple-darwin
+ runner: macos-latest
+ library: libsecretspec_ffi.dylib
+ deployment_target: "12.0"
+ rustflags: -C strip=symbols
+ - rid: win32-x86-64
+ target: x86_64-pc-windows-msvc
+ runner: windows-latest
+ library: secretspec_ffi.dll
+ rustflags: -C strip=symbols -C target-feature=+crt-static
+ - rid: win32-aarch64
+ target: aarch64-pc-windows-msvc
+ runner: windows-11-arm
+ library: secretspec_ffi.dll
+ rustflags: -C strip=symbols -C target-feature=+crt-static
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Sync SDK package versions
+ shell: bash
+ run: bash scripts/sync-sdk-versions.sh
+
+ - name: Install verified rustup in manylinux
+ if: matrix.container
+ shell: bash
+ run: |
+ bash scripts/install-rustup.sh
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - name: Install Rust
+ run: rustup toolchain install
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "11"
+
+ - name: Build native resolver
+ shell: bash
+ env:
+ MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment_target }}
+ RUSTFLAGS: ${{ matrix.rustflags }}
+ run: >-
+ cargo build -p secretspec-ffi --release
+ --target ${{ matrix.target }}
+
+ - name: Verify glibc portability (glibc <= 2.28, no libdbus)
+ if: matrix.container
+ shell: bash
+ run: >-
+ bash scripts/check-linux-portability.sh
+ "target/${{ matrix.target }}/release/${{ matrix.library }}"
+
+ - name: Verify Windows CRT is statically linked
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ rustup component add llvm-tools-preview
+ host="$(rustc -vV | sed -n 's/^host: //p')"
+ llvm_objdump="$(rustc --print sysroot)/lib/rustlib/$host/bin/llvm-objdump"
+ imports="$("$llvm_objdump" -p \
+ "target/${{ matrix.target }}/release/${{ matrix.library }}")"
+ if grep -Eiq 'DLL Name: (VCRUNTIME|MSVCP)' <<<"$imports"; then
+ echo "the packaged resolver still depends on the MSVC runtime" >&2
+ grep -Ei 'DLL Name: (VCRUNTIME|MSVCP)' <<<"$imports" >&2
+ exit 1
+ fi
+
+ - name: Run JVM SDK tests against native resolver
+ shell: bash
+ env:
+ SECRETSPEC_FFI_LIB: ${{ github.workspace }}/target/${{ matrix.target }}/release/${{ matrix.library }}
+ run: >-
+ cd secretspec-jvm && gradle test
+
+ - name: Stage native Jar asset
+ shell: bash
+ run: |
+ mkdir -p "staged/${{ matrix.rid }}"
+ cp "target/${{ matrix.target }}/release/${{ matrix.library }}" \
+ "staged/${{ matrix.rid }}"
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: jvm-native-${{ matrix.rid }}
+ path: staged/${{ matrix.rid }}
+
+ musl:
+ name: ${{ matrix.rid }}
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x86-64-musl
+ target: x86_64-unknown-linux-musl
+ runner: ubuntu-latest
+ image: quay.io/pypa/musllinux_1_2_x86_64:2026.08.05-1@sha256:c7b2187aa4d095a8da73ea4db96acefd46701a13439a9cc4546f5d61a4b5bba1
+ - rid: linux-aarch64-musl
+ target: aarch64-unknown-linux-musl
+ runner: ubuntu-24.04-arm
+ image: quay.io/pypa/musllinux_1_2_aarch64:2026.08.05-1@sha256:668c455aeddf5e363bd6fd801f10b369a634a9f01974876cee8c8c518b44ef10
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Sync SDK package versions
+ run: bash scripts/sync-sdk-versions.sh
+ - name: Build dynamically loadable musl resolver
+ shell: bash
+ run: |
+ docker run --rm \
+ --volume "$GITHUB_WORKSPACE:/workspace" \
+ --workdir /workspace \
+ --env CARGO_TARGET_DIR=/workspace/target \
+ --env "RUSTFLAGS=-C target-feature=-crt-static -C strip=symbols" \
+ "${{ matrix.image }}" \
+ bash -c '
+ set -euo pipefail
+ bash scripts/install-rustup.sh
+ export PATH="$HOME/.cargo/bin:$PATH"
+ rustup toolchain install
+ cargo build -p secretspec-ffi --release \
+ --target "${{ matrix.target }}"
+ '
+ test -f \
+ "target/${{ matrix.target }}/release/libsecretspec_ffi.so"
+ - name: Verify musl portability
+ shell: bash
+ run: |
+ library="target/${{ matrix.target }}/release/libsecretspec_ffi.so"
+ dynamic="$(readelf -d "$library")"
+ needed="$(grep NEEDED <<<"$dynamic")"
+ case "${{ matrix.target }}" in
+ x86_64-unknown-linux-musl)
+ expected_libc=libc.musl-x86_64.so.1
+ ;;
+ aarch64-unknown-linux-musl)
+ expected_libc=libc.musl-aarch64.so.1
+ ;;
+ esac
+ # ARM musl's libgcc_s exports a compatibility symbol version named
+ # GLIBC_2.0, so inspect the actual dynamic dependencies instead.
+ if grep -q '\[libc\.so\.6\]' <<<"$needed" ||
+ ! grep -Fq "[$expected_libc]" <<<"$needed"; then
+ echo "$library does not use the expected musl libc" >&2
+ echo "$needed" >&2
+ exit 1
+ fi
+ if grep -q dbus <<<"$needed"; then
+ echo "$library links libdbus dynamically" >&2
+ echo "$needed" >&2
+ exit 1
+ fi
+ - name: Stage native Jar asset
+ run: |
+ mkdir -p "staged/${{ matrix.rid }}/native"
+ # MUSL and GLIBC libraries must both reside in linux-x86-64 or linux-aarch64
+ # Rename the library so they can coexist in the same directory
+ cp "target/${{ matrix.target }}/release/libsecretspec_ffi.so" \
+ "staged/${{ matrix.rid }}/libsecretspec_musl_ffi.so"
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: jvm-native-${{ matrix.rid }}
+ path: staged/${{ matrix.rid }}
+
+ package:
+ name: Jar package
+ needs: [native, musl]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Sync SDK package versions
+ run: bash scripts/sync-sdk-versions.sh
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "11"
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: jvm-native-*
+ path: staged
+ - name: Place runtime assets
+ shell: bash
+ run: |
+ for artifact in staged/jvm-native-*; do
+ rid="${artifact##*/jvm-native-}"
+ # MUSL and GLIBC libraries must both reside in linux-x86-64 or linux-aarch64, remove the suffix
+ mkdir -p "secretspec-jvm/src/main/resources/com/sun/jna/${rid%-musl}"
+ cp -R "$artifact/"* "secretspec-jvm/src/main/resources/com/sun/jna/${rid%-musl}/"
+ done
+ - name: Pack
+ run: >-
+ cd secretspec-jvm && gradle assemble
+ - name: Verify runtime assets are present
+ shell: bash
+ run: |
+ # The staged directories come from the same matrix that builds the
+ # native libraries, so this check cannot drift from the matrix.
+ package="$(find artifacts -name '*.jar' -print -quit)"
+ shopt -s nullglob
+ staged=(staged/jvm-native-*)
+ if [ "${#staged[@]}" -eq 0 ]; then
+ echo "no staged native artifacts were downloaded" >&2
+ exit 1
+ fi
+ for artifact in "${staged[@]}"; do
+ rid="${artifact##*/jvm-native-}"
+ if [ "${rid%-musl}" = "${rid}" ] ; then
+ unzip -l "$package" | grep -q "com/sun/jna/${rid%-musl}/"
+ else
+ unzip -l "$package" | grep -q "com/sun/jna/${rid%-musl}/libsecretspec_musl_ffi.so"
+ fi
+ done
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: jar-file
+ path: artifacts/*.jar
+
+ consumer:
+ name: consume ${{ matrix.rid }}
+ needs: [package]
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x86-64
+ runner: ubuntu-latest
+ musl: false
+ - rid: linux-aarch64
+ runner: ubuntu-24.04-arm
+ musl: false
+ - rid: linux-x86-64-musl
+ runner: ubuntu-latest
+ musl: true
+ - rid: linux-aarch64-musl
+ runner: ubuntu-24.04-arm
+ musl: true
+ - rid: darwin-x86-64
+ runner: macos-15-intel
+ musl: false
+ - rid: darwin-aarch64
+ runner: macos-latest
+ musl: false
+ - rid: win32-x86-64
+ runner: windows-latest
+ musl: false
+ - rid: win32-aarch64
+ runner: windows-11-arm
+ musl: false
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: jar-file
+ path: artifacts
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "11"
+
+ - name: Run tests
+ if: matrix.musl == false && runner.os != 'Windows'
+ shell: bash
+ run: |
+ echo TODO
+
+ - name: Run tests on Windows
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ echo TODO
+
+ - name: Run tests on Alpine
+ if: matrix.musl
+ shell: bash
+ run: |
+ docker run --rm \
+ --volume "$GITHUB_WORKSPACE:/workspace" \
+ --volume "$RUNNER_TEMP:/runner" \
+ --workdir /runner \
+ eclipse-temurin:11-jdk-alpine \
+ sh -c '
+ echo TODO
+ '
+
+ publish:
+ name: publish to central repository
+ if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || inputs.publish
+ needs: [consumer]
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write # no long-lived key
+ contents: read
+ steps:
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "11"
+ cache: gradle
+ server-id: central
+ server-username: MAVEN_CENTRAL_USERNAME
+ server-password: MAVEN_CENTRAL_PASSWORD
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: jar-file
+ path: artifacts
+ - name: Publish package
+ env:
+ MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ GPG_SIGNING_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+ GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+ run: >-
+ cd secretspec-jvm && gradle publishToMavenCentral --no-daemon
diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml
index 1ec9a3d6..41db985a 100644
--- a/.github/workflows/pre-release.yml
+++ b/.github/workflows/pre-release.yml
@@ -128,6 +128,14 @@ jobs:
contents: write
uses: ./.github/workflows/swift-package.yml
+ jvm:
+ name: JVM package
+ needs: authorize
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/jvm-package.yml
+
release-artifacts:
name: CLI release artifacts
needs: authorize
diff --git a/.gitignore b/.gitignore
index 441ba8be..35811c2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,10 @@ target
/.build/
/secretspec-swift/Artifacts/
+# Java local artifacts and build output
+/secretspec-jvm/build/
+/secretspec-jvm/.gradle/
+
.idea/
+
+.vscode/
diff --git a/conformance/README.md b/conformance/README.md
index c409859c..b38eb61a 100644
--- a/conformance/README.md
+++ b/conformance/README.md
@@ -64,3 +64,5 @@ relative to the repo root:
- Swift (0.18+, macOS):
`swift test --filter SecretSpecTests.testCrossLanguageConformance` after
staging the local XCFramework as described in `secretspec-swift/README.md`
+- JVM (0.XX+):
+ `cd secretspec-jvm && gradle test`
\ No newline at end of file
diff --git a/conformance/run.sh b/conformance/run.sh
index 628886d9..d9ac2775 100755
--- a/conformance/run.sh
+++ b/conformance/run.sh
@@ -97,6 +97,10 @@ run_swift() { (
"$artifact" "$SECRETSPEC_FFI_LIB"
swift test --filter SecretSpecTests.testCrossLanguageConformance
); }
+run_jvm() { (
+ cd secretspec-jvm
+ gradle test
+); }
run "Python" python run_python
run "Go" go run_go
@@ -111,6 +115,7 @@ else
echo "==> SKIP Swift (the XCFramework SDK is macOS-only)"
names+=("Swift"); statuses+=("SKIP")
fi
+run "JVM" jvm run_jvm
echo
echo "==> Conformance summary"
diff --git a/devenv.nix b/devenv.nix
index f8a4aeec..d7e62522 100644
--- a/devenv.nix
+++ b/devenv.nix
@@ -59,6 +59,12 @@
ffi.enable = true;
'';
};
+ languages.java = {
+ enable = true;
+ jdk.package = pkgs.jdk11;
+ gradle.enable = true;
+ gradle.package = pkgs.gradle.override { java = pkgs.jdk21; };
+ };
packages = [
# coverage testing
@@ -103,6 +109,7 @@
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER = muslcc;
MUSL_CC = muslcc;
MUSL_STATIC_LDFLAGS = "-L${pkgs.pkgsStatic.libunwind}/lib";
+ SECRETSPEC_JVM_TARGET_JDK = "${pkgs.jdk11}";
}
);
diff --git a/docs/astro.config.ts b/docs/astro.config.ts
index 4a8a3b20..283ef37f 100644
--- a/docs/astro.config.ts
+++ b/docs/astro.config.ts
@@ -369,6 +369,11 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil
slug: "sdk/swift",
badge: { text: "0.18+", variant: "note" },
},
+ {
+ label: "JVM",
+ slug: "sdk/jvm",
+ badge: { text: "0.XX+", variant: "note" },
+ },
],
},
{
diff --git a/docs/src/content/docs/sdk/jvm.mdx b/docs/src/content/docs/sdk/jvm.mdx
new file mode 100644
index 00000000..9a51824f
--- /dev/null
+++ b/docs/src/content/docs/sdk/jvm.mdx
@@ -0,0 +1,82 @@
+---
+title: JVM SDK
+description: Resolve SecretSpec secrets from Java Virtual Machine languages
+---
+
+import { Code } from 'astro:components';
+import quickStartExample from '../../../../../secretspec-jvm/examples/quick_start/QuickStart.java?raw';
+import scopesExample from '../../../../../secretspec-jvm/examples/scopes/Scopes.java?raw';
+import reportExample from '../../../../../secretspec-jvm/examples/report/Report.java?raw';
+import typedAccessExample from '../../../../../secretspec-jvm/examples/typed_access/TypedAccess.java?raw';
+import asPathExample from '../../../../../secretspec-jvm/examples/as_path/AsPath.java?raw';
+
+> **Version compatibility:** Available since SecretSpec 0.XX.
+
+The JVM SDK (`org.cachix.secretspec-jvm`) is a thin client over the same Rust resolver as
+the CLI. Every provider, fallback chain, profile, generator, reference, and
+`as_path` secret therefore works without JVM-side resolution logic.
+
+## Install (0.XX+)
+
+The package targets JDK 11 and includes native resolvers for glibc and musl
+Linux x64/Arm64, macOS x64/Arm64, and Windows x64/Arm64. Windows assets
+statically include the C runtime. No separate SecretSpec CLI, native library,
+Visual C++ Redistributable, or system `libdbus` installation is needed.
+
+## Quick start
+
+
+
+`get()` returns the inline value, or the readable file path for an `as_path`
+secret. A missing required secret throws `MissingRequiredException`; its
+`missing` property contains the secret names. Other failures throw
+`SecretSpecException`, whose `kind` property is a stable error category.
+
+## Scopes (0.17+)
+
+Use `withScope("api")` to resolve only a named `[scopes.api]` subset. The
+selected name is available as `Resolved.getScope()` and `ResolutionReport.getScope()`:
+
+
+
+## Value-free preflight
+
+`report()` returns the inventory view exposed by `secretspec check --json`.
+It never carries values. Missing required secrets appear with
+`getStatus() == "missing_required"` rather than throwing, so incomplete deployments
+can still be inspected.
+
+
+
+## Typed access
+
+Generate an idiomatic language model from the manifest schema:
+
+```bash
+secretspec schema |
+ quicktype -s schema --top-level AppSecrets --lang java -o AppSecrets.java
+```
+
+Then deserialize the SDK's flat field map:
+
+
+
+The schema models successful resolution: required, defaulted, and generated
+secrets are non-nullable, and profile-specific schemas include inherited
+default-profile fields.
+
+## Files (`as_path`)
+
+File-shaped secrets are materialized as mode-0400 temporary files. The returned
+path must remain valid after `load()`, so the caller owns its lifetime.
+`Resolved` implements `AutoCloseable`; use a `try-with-resources` declaration or call `close()`
+to remove these files deterministically:
+
+
+
+## Native loading
+
+The Jar runtime asset is selected automatically. For local SDK development,
+`SECRETSPEC_FFI_LIB` can point to a particular `libsecretspec_ffi` build. From
+a SecretSpec source checkout, the SDK also searches an ancestor Cargo
+`target/debug` or `target/release` directory.
diff --git a/docs/src/content/docs/sdk/overview.md b/docs/src/content/docs/sdk/overview.md
index 7ffa4295..3671ffb1 100644
--- a/docs/src/content/docs/sdk/overview.md
+++ b/docs/src/content/docs/sdk/overview.md
@@ -35,6 +35,8 @@ that core rather than a reimplementation:
- **PHP** prefers an [ext-php-rs](https://github.com/davidcole1340/ext-php-rs)
extension that embeds the resolver (working under FPM with no `ffi.enable`),
and falls back to loading the same C ABI at runtime through `ext-ffi`.
+- **JVM (0.XX+)** loads the same C ABI with JNA from a runtime-specific
+ native asset in the Jar package.
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
@@ -60,7 +62,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), and [Swift (0.18+)](/sdk/swift).
+[C# (0.16+)](/sdk/csharp), [Swift (0.18+)](/sdk/swift)
+and [JVM languages (0.XX+)](/sdk/jvm).
Every builder also takes a [scope (0.17+)](/concepts/scopes/),
resolving only a named subset of the profile and returning the selected name on
@@ -108,6 +111,8 @@ no runtime library path to set:
- **Node.js** builds the resolver into a napi-rs addon.
- **PHP** ships as a normal PHP extension (provisioned like `ext-redis`), with an
`ext-ffi` fallback that dlopens the bundled `cdylib`.
+- **JVM** ships the `cdylib` as runtime-specific native assets in one
+ Jar package and loads the matching asset through JNA.
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`
@@ -131,6 +136,7 @@ SecretSpec 0.17.
| Swift (0.18+) | — | — | ✓ | ✓ | — | — |
| PHP | ✓ | ✓ | — | ✓ | ✓ (0.17+) | — |
| Haskell (source) | ✓ | — | — | — | ✓ (0.17+) | — |
+| JVM (0.XX+) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Most Linux packages build against a manylinux_2_28 baseline (glibc 2.28 or
newer); the C# package additionally ships musl Linux assets. Hackage
diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh
index 09863b92..537ad638 100755
--- a/scripts/ci-sdks.sh
+++ b/scripts/ci-sdks.sh
@@ -120,6 +120,9 @@ echo "==> C# / .NET"
( cd secretspec-dotnet && dotnet run --project tests/SecretSpec.Tests --configuration Release )
( cd secretspec-dotnet && find examples -name '*.csproj' -exec dotnet build {} \; )
+echo "==> JVM"
+( cd secretspec-jvm && gradle assemble )
+
echo "==> PHP"
# The PHP SDK has two native backends over the same resolver; exercise both.
# The Composer manifest is at the repo root (so Packagist can read it from the
diff --git a/scripts/sync-sdk-versions.sh b/scripts/sync-sdk-versions.sh
index e4f665c7..eb1d993f 100755
--- a/scripts/sync-sdk-versions.sh
+++ b/scripts/sync-sdk-versions.sh
@@ -125,6 +125,16 @@ update_file() {
END { if (!changed) exit 1 }
' "$file" > "$tmp"
;;
+ gradle-properties)
+ awk -v version="$workspace_version" '
+ !changed && /^[[:space:]]*secretspec\.version[[:space:]]*=/ {
+ sub(/secretspec\.version[[:space:]]*=[[:space:]]*[^#]*/, "secretspec.version=" version)
+ changed = 1
+ }
+ { print }
+ END { if (!changed) exit 1 }
+ ' "$file" > "$tmp"
+ ;;
*)
echo "unknown manifest kind: $kind" >&2
rm -f "$tmp"
@@ -141,6 +151,7 @@ update_file secretspec-hs/secretspec.cabal cabal
update_file secretspec-node/package.json package-json
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
echo "synced SDK package versions to $workspace_version"
diff --git a/secretspec-jvm/README.md b/secretspec-jvm/README.md
new file mode 100644
index 00000000..f0048f46
--- /dev/null
+++ b/secretspec-jvm/README.md
@@ -0,0 +1,87 @@
+# SecretSpec for the Java Virtual Machine
+
+> Supported starting with SecretSpec 0.XX.
+
+`org.cachix.SecretSpec` is the Java SDK for
+[SecretSpec](https://secretspec.dev/), the declarative secrets manager. It is a
+thin client over the shared Rust resolver, so every provider, fallback chain,
+profile, generator, and `as_path` secret behaves exactly like the CLI and the
+other language SDKs.
+
+
+```java
+import org.cachix.secretspec.SecretSpec;
+
+public class MyApplication {
+
+ public static void main() {
+ var resolved = SecretSpec.builder()
+ .withProvider("keyring://")
+ .withProfile("production")
+ .withReason("boot web app")
+ .load();
+
+ var secrets = resolved.getSecrets():
+ System.out.println(secrets.get("DATABASE_URL").get());
+ resolved.setAsSystemProperties();
+ }
+}
+```
+
+A missing required secret throws `MissingRequiredException`, whose `missing`
+property contains the names. Other failures throw `SecretSpecException`, with a
+stable `kind`.
+
+## Scopes (0.17+)
+
+Use `withScope("api")` to resolve only a named `[scopes.api]` subset. Both
+`Resolved.getScope()` and `ResolutionReport.getScope()` return the selected scope:
+
+```java
+var resolved = SecretSpec.builder().withScope("api").load();
+```
+
+## Value-free reports
+
+`report()` returns the same inventory/preflight view as
+`secretspec check --json`. It never exposes values, and a missing required
+secret is an entry with `getStatus() == "missing_required"` rather than an exception.
+
+```java
+var report = SecretSpec.builder()
+ .withProfile("production")
+ .withReason("deployment preflight")
+ .report();
+
+for (var secret : report.getSecrets())
+ System.out.println("%s: %s".formatted(secret.getName(), secret.getStatus()));
+```
+
+## Typed access
+
+Generate a Java type from the manifest, then deserialize `fieldsJson()`:
+
+```bash
+secretspec schema |
+ quicktype -s schema --top-level AppSecrets --lang java -o AppSecrets.java
+```
+
+```java
+var secrets = io.quicktype.Converter.fromJson(resolved.fieldsJson());
+```
+
+## Files and cleanup
+
+An `as_path` secret is materialized as a mode-0400 temporary file, and `get()`
+returns its path. `Resolved` implements `AutoCloseable`; keep the result in a
+`try-with-resources` declaration or call `close()` to remove those files when finished.
+
+## Native resolver
+
+The JAR file carries the resolver for glibc and musl Linux x64/Arm64,
+macOS x64/Arm64, and Windows x64/Arm64. Windows builds include the C runtime,
+so users do not need to install the Visual C++ Redistributable.
+
+During local SDK development, `SECRETSPEC_FFI_LIB` can point to an explicit
+`libsecretspec_ffi` build; the SDK also discovers a Cargo `target` directory
+when used from a SecretSpec source checkout.
diff --git a/secretspec-jvm/build.gradle.kts b/secretspec-jvm/build.gradle.kts
new file mode 100644
index 00000000..5d473c0d
--- /dev/null
+++ b/secretspec-jvm/build.gradle.kts
@@ -0,0 +1,90 @@
+plugins {
+ `java-library`
+ `maven-publish`
+ `signing`
+ id("com.vanniktech.maven.publish") version "0.30.0"
+}
+
+group = "org.cachix"
+
+version = providers.gradleProperty("secretspec.version").get()
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(11))
+ }
+ withSourcesJar()
+ withJavadocJar()
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ val jnaVersion = "5.19.1"
+ val jacksonVersion = "2.21.5"
+ val junitVersion = "5.14.4"
+ val junitPlatformVersion = "1.14.4"
+ val assertjVersion = "3.27.7"
+
+ implementation("net.java.dev.jna:jna:$jnaVersion")
+ implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
+
+ testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
+ testImplementation("org.assertj:assertj-core:$assertjVersion")
+
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitPlatformVersion")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+mavenPublishing {
+ publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL)
+
+ signAllPublications()
+
+ coordinates("org.cachix", "secretspec-jvm", version.toString())
+
+ pom {
+ name.set("SecretSpec JVM SDK")
+ description.set("JVM SDK for SecretSpec secret resolution")
+ url.set("https://secretspec.dev")
+
+ scm {
+ connection.set("scm:git:https://github.com/cachix/secretspec.git")
+ developerConnection.set("scm:git:ssh://github.com/cachix/secretspec.git")
+ url.set("https://github.com/cachix/secretspec")
+ }
+
+ licenses {
+ license {
+ name.set("Apache-2.0")
+ url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
+ }
+ }
+
+ developers {
+ developer {
+ id.set("cachix")
+ name.set("Cachix Team")
+ email.set("support@cachix.org")
+ organization.set("Cachix")
+ organizationUrl.set("https://www.cachix.org")
+ }
+ }
+
+ issueManagement {
+ system.set("GitHub Issues")
+ url.set("https://github.com/cachix/secretspec/issues")
+ }
+
+ ciManagement {
+ system.set("GitHub Actions")
+ url.set("https://github.com/cachix/secretspec/actions")
+ }
+ }
+}
diff --git a/secretspec-jvm/examples/as_path/Scopes.java b/secretspec-jvm/examples/as_path/Scopes.java
new file mode 100644
index 00000000..2dd78b0e
--- /dev/null
+++ b/secretspec-jvm/examples/as_path/Scopes.java
@@ -0,0 +1,14 @@
+
+import org.cachix.secretspec.SecretSpec;
+
+public class Scopes {
+
+ public static void main() {
+ try(var resolved = SecretSpec.builder().withReason("TLS boot").load()) {
+ var secrets = resolved.getSecrets();
+ var certificatePath = secrets.get("TLS_CERT").get();
+ // Use the certificate before resolved is disposed.
+ System.out.println(certificatePath);
+ }
+ }
+}
diff --git a/secretspec-jvm/examples/quick_start/QuickStart.java b/secretspec-jvm/examples/quick_start/QuickStart.java
new file mode 100644
index 00000000..550c2404
--- /dev/null
+++ b/secretspec-jvm/examples/quick_start/QuickStart.java
@@ -0,0 +1,18 @@
+import org.cachix.secretspec.SecretSpec;
+
+public class QuickStart {
+
+ public static void main() {
+ try(var resolved = SecretSpec.builder()
+ .withProvider("keyring://")
+ .withProfile("production")
+ .withReason("boot web app")
+ .load()
+ ) {
+ System.out.println(resolved.getProvider() + " (" + resolved.getProfile() + ")");
+ var secrets = resolved.getSecrets();
+ System.out.println(secrets.get("DATABASE_URL").get());
+ resolved.setAsSystemProperties();
+ }
+ }
+}
diff --git a/secretspec-jvm/examples/report/Report.java b/secretspec-jvm/examples/report/Report.java
new file mode 100644
index 00000000..91d7bfed
--- /dev/null
+++ b/secretspec-jvm/examples/report/Report.java
@@ -0,0 +1,14 @@
+import org.cachix.secretspec.SecretSpec;
+
+public class Report {
+
+ public static void main() {
+ var report = SecretSpec.builder()
+ .withProfile("production")
+ .withReason("deployment preflight")
+ .report();
+
+ for (var secret : report.getSecrets())
+ System.out.println(secret.getName() + ": " + secret.getStatus());
+ }
+}
diff --git a/secretspec-jvm/examples/scopes/Scopes.java b/secretspec-jvm/examples/scopes/Scopes.java
new file mode 100644
index 00000000..bfbc92cc
--- /dev/null
+++ b/secretspec-jvm/examples/scopes/Scopes.java
@@ -0,0 +1,10 @@
+import org.cachix.secretspec.SecretSpec;
+
+public class Scopes {
+
+ public static void main() {
+ try(var resolved = SecretSpec.builder().withScope("api").load()) {
+
+ }
+ }
+}
diff --git a/secretspec-jvm/examples/typed_access/AppSecrets.java b/secretspec-jvm/examples/typed_access/AppSecrets.java
new file mode 100644
index 00000000..90d26008
--- /dev/null
+++ b/secretspec-jvm/examples/typed_access/AppSecrets.java
@@ -0,0 +1,12 @@
+package io.quicktype;
+
+import com.fasterxml.jackson.annotation.*;
+
+public class AppSecrets {
+ private String databaseURL;
+
+ @JsonProperty("DATABASE_URL")
+ public String getDatabaseURL() { return databaseURL; }
+ @JsonProperty("DATABASE_URL")
+ public void setDatabaseURL(String value) { this.databaseURL = value; }
+}
diff --git a/secretspec-jvm/examples/typed_access/Converter.java b/secretspec-jvm/examples/typed_access/Converter.java
new file mode 100644
index 00000000..ca185382
--- /dev/null
+++ b/secretspec-jvm/examples/typed_access/Converter.java
@@ -0,0 +1,101 @@
+// To use this code, add the following Maven dependency to your project:
+//
+//
+// com.fasterxml.jackson.core : jackson-databind : 2.9.0
+// com.fasterxml.jackson.datatype : jackson-datatype-jsr310 : 2.9.0
+//
+// Import this package:
+//
+// import io.quicktype.Converter;
+//
+// Then you can deserialize a JSON string with
+//
+// AppSecrets data = Converter.fromJsonString(jsonString);
+
+package io.quicktype;
+
+import java.io.IOException;
+import com.fasterxml.jackson.databind.*;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.util.*;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+
+public class Converter {
+ // Date-time helpers
+
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = new DateTimeFormatterBuilder()
+ .appendOptional(DateTimeFormatter.ISO_DATE_TIME)
+ .appendOptional(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+ .appendOptional(DateTimeFormatter.ISO_INSTANT)
+ .appendOptional(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SX"))
+ .appendOptional(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssX"))
+ .appendOptional(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+ .toFormatter()
+ .withZone(ZoneOffset.UTC);
+
+ public static OffsetDateTime parseDateTimeString(String str) {
+ return ZonedDateTime.from(Converter.DATE_TIME_FORMATTER.parse(str)).toOffsetDateTime();
+ }
+
+ private static final DateTimeFormatter TIME_FORMATTER = new DateTimeFormatterBuilder()
+ .appendOptional(DateTimeFormatter.ISO_TIME)
+ .appendOptional(DateTimeFormatter.ISO_OFFSET_TIME)
+ .parseDefaulting(ChronoField.YEAR, 2020)
+ .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
+ .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
+ .toFormatter()
+ .withZone(ZoneOffset.UTC);
+
+ public static OffsetTime parseTimeString(String str) {
+ return ZonedDateTime.from(Converter.TIME_FORMATTER.parse(str)).toOffsetDateTime().toOffsetTime();
+ }
+ // Serialize/deserialize helpers
+
+ public static AppSecrets fromJsonString(String json) throws IOException {
+ return getObjectReader().readValue(json);
+ }
+
+ public static String toJsonString(AppSecrets obj) throws JsonProcessingException {
+ return getObjectWriter().writeValueAsString(obj);
+ }
+
+ private static ObjectReader reader;
+ private static ObjectWriter writer;
+
+ private static void instantiateMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.findAndRegisterModules();
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+ SimpleModule module = new SimpleModule();
+ module.addDeserializer(OffsetDateTime.class, new JsonDeserializer() {
+ @Override
+ public OffsetDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
+ String value = jsonParser.getText();
+ return Converter.parseDateTimeString(value);
+ }
+ });
+ mapper.registerModule(module);
+ reader = mapper.readerFor(AppSecrets.class);
+ writer = mapper.writerFor(AppSecrets.class);
+ }
+
+ private static ObjectReader getObjectReader() {
+ if (reader == null) instantiateMapper();
+ return reader;
+ }
+
+ private static ObjectWriter getObjectWriter() {
+ if (writer == null) instantiateMapper();
+ return writer;
+ }
+}
diff --git a/secretspec-jvm/examples/typed_access/TypedAccess.java b/secretspec-jvm/examples/typed_access/TypedAccess.java
new file mode 100644
index 00000000..f1dbe69c
--- /dev/null
+++ b/secretspec-jvm/examples/typed_access/TypedAccess.java
@@ -0,0 +1,12 @@
+import org.cachix.secretspec.SecretSpec;
+import io.quicktype.Converter;
+
+public class TypedAccess {
+
+ public static void main() {
+ try(var resolved = SecretSpec.builder().load()) {
+ AppSecrets typed = Converter.fromJsonString(resolved.fieldsJson());
+ System.out.println(typed.getDatabaseURL());
+ }
+ }
+}
diff --git a/secretspec-jvm/gradle.properties b/secretspec-jvm/gradle.properties
new file mode 100644
index 00000000..6486bba4
--- /dev/null
+++ b/secretspec-jvm/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.java.installations.auto-detect=true
+org.gradle.java.installations.auto-download=false
+org.gradle.java.installations.fromEnv=SECRETSPEC_JVM_TARGET_JDK
+secretspec.version=0.0.0-SNAPSHOT
diff --git a/secretspec-jvm/settings.gradle.kts b/secretspec-jvm/settings.gradle.kts
new file mode 100644
index 00000000..dd4de839
--- /dev/null
+++ b/secretspec-jvm/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "secretspec-jvm"
diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/JsonContracts.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/JsonContracts.java
new file mode 100644
index 00000000..2dd755bf
--- /dev/null
+++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/JsonContracts.java
@@ -0,0 +1,412 @@
+package org.cachix.secretspec;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import java.util.Map;
+import java.util.Objects;
+import java.util.List;
+
+
+final class JsonContracts {
+
+ static final int RESOLVE_SCHEMA_VERSION = 2;
+ static final int REPORT_SCHEMA_VERSION = 1;
+
+ private JsonContracts() {
+ // No instances
+ }
+}
+
+final class SecretSpecJsonContext {
+
+ public static final ObjectMapper MAPPER = JsonMapper.builder()
+ .defaultPropertyInclusion(JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.USE_DEFAULTS))
+ .build();
+
+ public static final TypeReference> RESOLVE_ENVELOPE =
+ new TypeReference<>() {};
+
+ public static final TypeReference> REPORT_ENVELOPE =
+ new TypeReference<>() {};
+
+ public static final TypeReference