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> SECRET_FIELDS = + new TypeReference<>() {}; + + private SecretSpecJsonContext() { + // No instances. + } +} + +final class ResolveRequest { + + @JsonProperty("path") + private String path; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("profile") + private String profile; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("reason") + private String reason; + + @JsonProperty("no_values") + private boolean noValues; + + @JsonProperty("mode") + private String mode; + + public ResolveRequest() { + } + + public ResolveRequest(ResolveRequest request) { + this.path = request.path; + this.provider = request.provider; + this.profile = request.profile; + this.scope = request.scope; + this.reason = request.reason; + this.noValues = request.noValues; + this.mode = request.mode; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getProfile() { + return profile; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public boolean isNoValues() { + return noValues; + } + + public void setNoValues(boolean noValues) { + this.noValues = noValues; + } + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + @Override + public int hashCode() { + return Objects.hash( + path, + provider, + profile, + scope, + reason, + noValues, + mode + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ResolveRequest) o; + return Objects.equals(path, other.path) + && Objects.equals(provider, other.provider) + && Objects.equals(profile, other.profile) + && Objects.equals(scope, other.scope) + && Objects.equals(reason, other.reason) + && noValues == other.noValues + && Objects.equals(mode, other.mode); + } +} + +final class Envelope { + + @JsonProperty("ok") + private boolean ok; + + @JsonProperty("response") + private T response; + + @JsonProperty("error") + private ErrorContract error; + + public Envelope() { + } + + public boolean isOk() { + return ok; + } + + public T getResponse() { + return response; + } + + public ErrorContract getError() { + return error; + } + + @Override + public int hashCode() { + return Objects.hash( + ok, + response, + error + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + @SuppressWarnings("unchecked") + var other = (Envelope) o; + return Objects.equals(ok, other.ok) + && Objects.equals(response, other.response) + && Objects.equals(error, other.error); + } + +} + +final class ErrorContract { + + @JsonProperty("kind") + private String kind; + + @JsonProperty("message") + private String message; + + public ErrorContract() { + } + + public String getKind() { + return kind; + } + + public String getMessage() { + return message; + } + + @Override + public int hashCode() { + return Objects.hash( + kind, + message + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ErrorContract) o; + return Objects.equals(kind, other.kind) + && Objects.equals(message, other.message); + } +} + +final class ResolveResponseContract { + + @JsonProperty("schema_version") + private int schemaVersion; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("profile") + private String profile; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("secrets") + private Map secrets; + + @JsonProperty("missing_required") + private List missingRequired; + + @JsonProperty("missing_optional") + private List missingOptional; + + public ResolveResponseContract() { + } + + public int getSchemaVersion() { + return schemaVersion; + } + + public String getProvider() { + return provider; + } + + public String getProfile() { + return profile; + } + + public String getScope() { + return scope; + } + + public Map getSecrets() { + return secrets; + } + + public List getMissingRequired() { + return missingRequired; + } + + public List getMissingOptional() { + return missingOptional; + } + + @Override + public int hashCode() { + return Objects.hash( + schemaVersion, + provider, + profile, + scope, + secrets, + missingRequired, + missingOptional + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ResolveResponseContract) o; + return Objects.equals(schemaVersion, other.schemaVersion) + && Objects.equals(provider, other.provider) + && Objects.equals(profile, other.profile) + && Objects.equals(scope, other.scope) + && Objects.equals(secrets, other.secrets) + && Objects.equals(missingRequired, other.missingRequired) + && Objects.equals(missingOptional, other.missingOptional); + } +} + +final class ReportResponseContract { + + @JsonProperty("schema_version") + private int schemaVersion; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("profile") + private String profile; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("secrets") + private List secrets; + + public ReportResponseContract() { + } + + public int getSchemaVersion() { + return schemaVersion; + } + + public String getProvider() { + return provider; + } + + public String getProfile() { + return profile; + } + + public String getScope() { + return scope; + } + + public List getSecrets() { + return secrets; + } + + @Override + public int hashCode() { + return Objects.hash( + schemaVersion, + provider, + profile, + scope, + secrets + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ReportResponseContract) o; + return Objects.equals(schemaVersion, other.schemaVersion) + && Objects.equals(provider, other.provider) + && Objects.equals(profile, other.profile) + && Objects.equals(scope, other.scope) + && Objects.equals(secrets, other.secrets); + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/MissingRequiredException.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/MissingRequiredException.java new file mode 100644 index 00000000..257250e3 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/MissingRequiredException.java @@ -0,0 +1,32 @@ +package org.cachix.secretspec; + +import static java.util.stream.Collectors.toUnmodifiableList; + +import java.util.List; +import java.util.stream.StreamSupport; + + +/** + * Required secrets that could not be resolved. + */ +public final class MissingRequiredException extends SecretSpecException { + + MissingRequiredException(Iterable missing) { + super("missing_required", buildMessage(missing)); + var missingStream = StreamSupport.stream(missing.spliterator(), false); + this.missing = missingStream.collect(toUnmodifiableList()); + } + + /** + * The unresolved required secret names. + */ + private final List missing; + + public List getMissing() { + return missing; + } + + private static String buildMessage(Iterable missing) { + return "missing required secret(s): " + String.join(", ", missing); + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/Native.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/Native.java new file mode 100644 index 00000000..1956e68e --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/Native.java @@ -0,0 +1,198 @@ +package org.cachix.secretspec; + +import com.sun.jna.Library; +import com.sun.jna.Pointer; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + + +final class Native { + + private static final String LIBRARY_NAME = "secretspec_ffi"; + private static final String MUSL_LIBRARY_NAME = "secretspec_musl_ffi"; + private static final SecretSpecFFI BINDINGS; + + interface SecretSpecFFI extends Library { + Pointer secretspec_resolve(String requestJson); + void secretspec_free(Pointer pointer); + Pointer secretspec_abi_version(); + } + + static { + try { + String libPath = findLibraryPath(); + if (libPath != null) { + BINDINGS = com.sun.jna.Native.load(libPath, SecretSpecFFI.class); + } else if (shouldUseMusl()) { + /* + * JNA does not distinguish between musl and glibc. + * To ship two binaries targeting the same linux-arch in the Jar we must rename one of them. + * We rename the musl library. + */ + BINDINGS = com.sun.jna.Native.load(MUSL_LIBRARY_NAME, SecretSpecFFI.class); + } else { + BINDINGS = com.sun.jna.Native.load(LIBRARY_NAME, SecretSpecFFI.class); + } + } catch (UnsatisfiedLinkError error) { + throw new SecretSpecException("load", error.getMessage(), error); + } + } + + private Native() { + // No instances. + } + + static String resolve(String requestJson) { + Pointer responsePtr = Pointer.NULL; + try { + responsePtr = BINDINGS.secretspec_resolve(requestJson); + if (responsePtr == Pointer.NULL) { + throw new SecretSpecException("ffi", "secretspec_resolve returned null"); + } + + String result = responsePtr.getString(0, "UTF-8"); + if (result == null) { + throw new SecretSpecException("ffi", "secretspec_resolve returned invalid UTF-8"); + } + return result; + } catch (UnsatisfiedLinkError error) { + throw new SecretSpecException("load", error.getMessage(), error); + } finally { + if (responsePtr != Pointer.NULL) { + BINDINGS.secretspec_free(responsePtr); + } + } + } + + static String abiVersion() { + try { + Pointer pointer = BINDINGS.secretspec_abi_version(); + if (pointer == Pointer.NULL) { + throw new SecretSpecException("ffi", "secretspec_abi_version returned null"); + } + String version = pointer.getString(0, "UTF-8"); + if (version == null) { + throw new SecretSpecException("ffi", "secretspec_abi_version returned null"); + } + return version; + } catch (UnsatisfiedLinkError error) { + throw new SecretSpecException("load", error.getMessage(), error); + } + } + + private static String findLibraryPath() { + String explicitPath = System.getenv("SECRETSPEC_FFI_LIB"); + if (explicitPath != null && !explicitPath.trim().isEmpty()) { + return explicitPath; + } + + String os = System.getProperty("os.name").toLowerCase(); + String fileName; + if (os.contains("win")) { + fileName = "secretspec_ffi.dll"; + } else if (os.contains("mac")) { + fileName = "libsecretspec_ffi.dylib"; + } else { + fileName = "libsecretspec_ffi.so"; + } + + String[] starts = new String[]{ + System.getProperty("user.dir"), + Native.class.getProtectionDomain().getCodeSource() != null && + Native.class.getProtectionDomain().getCodeSource().getLocation() != null + ? new File(Native.class.getProtectionDomain().getCodeSource().getLocation().getPath()).getParent() + : null + }; + + for (String start : starts) { + if (start == null) continue; + + File directory = new File(start); + while (directory != null) { + String newest = null; + long newestTime = Long.MIN_VALUE; + + for (String profile : new String[]{"release", "debug"}) { + File candidate = new File(directory, "target" + File.separator + profile + File.separator + fileName); + if (candidate.exists() && candidate.lastModified() >= newestTime) { + newest = candidate.getAbsolutePath(); + newestTime = candidate.lastModified(); + } + } + + if (newest != null) { + return newest; + } + + directory = directory.getParentFile(); + } + } + + return null; + } + + private static boolean shouldUseMusl() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (!os.contains("linux")) { + return false; + } + + // Try running ldd + try { + ProcessBuilder pb = new ProcessBuilder("ldd", "--version"); + Process process = pb.start(); + + InputStream[] inputStreams = { + process.getInputStream(), + process.getErrorStream(), + }; + for (var inputStream : inputStreams) { + try ( + var reader = new BufferedReader(new InputStreamReader(inputStream)); + var lines = reader.lines(); + ) { + var found = lines + .map(line -> line.toLowerCase(Locale.ROOT)) + .anyMatch(line -> line.contains("musl")); + if (found) { + return true; + } + } + } + } catch (IOException ignored) { + } + + // Try finding musl-specific files + Path[] searchDirectories = { + Path.of("/lib"), + Path.of("/usr/lib"), + Path.of("/lib64"), + Path.of("/usr/lib64") + }; + for (Path candidate : searchDirectories) { + if (Files.exists(candidate) && Files.isDirectory(candidate)) { + try (var directoryContents = Files.list(candidate)) { + boolean found = directoryContents + .map(Path::getFileName) + .map(Path::toString) + .anyMatch( + name -> name.startsWith("ld-musl-") + && name.endsWith(".so.1") + ); + if (found) { + return true; + } + } catch (IOException ignored) { + } + } + } + return false; + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolutionReport.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolutionReport.java new file mode 100644 index 00000000..2d0c6363 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolutionReport.java @@ -0,0 +1,73 @@ +package org.cachix.secretspec; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + + +/** + * A value-free inventory/preflight snapshot. + */ +public final class ResolutionReport { + + ResolutionReport( + String provider, + String profile, + String scope, + Collection secrets + ) { + this.provider = provider; + this.profile = profile; + this.scope = scope; + this.secrets = List.copyOf(secrets); + } + + private final String provider; + private final String profile; + /** + * Selected manifest scope, or null for a full-profile report (0.17+). + */ + private final String scope; + private final List secrets; + + public String getProvider() { + return provider; + } + + public String getProfile() { + return profile; + } + + public String getScope() { + return scope; + } + + public List getSecrets() { + return secrets; + } + + @Override + public int hashCode() { + return Objects.hash( + provider, + profile, + scope, + secrets + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ResolutionReport) o; + return Objects.equals(provider, other.provider) + && Objects.equals(profile, other.profile) + && Objects.equals(scope, other.scope) + && Objects.equals(secrets, other.secrets); + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/Resolved.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/Resolved.java new file mode 100644 index 00000000..c01c1a81 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/Resolved.java @@ -0,0 +1,134 @@ +package org.cachix.secretspec; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.HashMap; + +import static java.util.Collections.unmodifiableMap; + + +/** + * A successful, value-carrying resolution. + */ +public final class Resolved implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private boolean disposed; + + Resolved( + String provider, + String profile, + String scope, + Map secrets, + Collection missingOptional + ) { + this.provider = provider; + this.profile = profile; + this.scope = scope; + this.secrets = Map.copyOf(secrets); + this.missingOptional = List.copyOf(missingOptional); + } + + private final String provider; + private final String profile; + /** + * Selected manifest scope, or null for a full-profile resolve (0.17+). + */ + private final String scope; + private final Map secrets; + private final List missingOptional; + + public String getProvider() { + return provider; + } + + public String getProfile() { + return profile; + } + + public String getScope() { + return scope; + } + + public Map getSecrets() { + return secrets; + } + + public List getMissingOptional() { + return missingOptional; + } + + /** + * Exports every present secret into the system properties. + */ + public void setAsSystemProperties() { + for (var entry : secrets.entrySet()) { + var name = entry.getKey(); + var secret = entry.getValue(); + var value = secret.get(); + if (value != null) + System.setProperty(name, value); + } + } + + /** + * Returns a flat secret-name-to-value map suitable for a generated typed + * deserializer. File-shaped secrets map to their paths; stripped values map to null. + */ + public Map fields() { + var fields = new HashMap(); + for (var key: secrets.keySet()) { + var secret = Optional.ofNullable(secrets.get(key)); + fields.put(key, secret.map(ResolvedSecret::get).orElse(null)); + } + return unmodifiableMap(fields); + } + + /** + * Serializes {@link #fields()} for a generated deserializer. + */ + public String fieldsJson() { + try { + return MAPPER.writeValueAsString(fields()); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize fields to JSON", e); + } + } + + /** + * Removes temporary files backing {@code as_path} secrets. + */ + public void close() { + if (disposed) + return; + + disposed = true; + UncheckedIOException firstError = null; + for (var secret : secrets.values()) { + if (!secret.isAsPath() || secret.getPath() == null) + continue; + + try { + var path = Paths.get(secret.getPath()); + Files.delete(path); + } + catch (IOException e) { + if (firstError == null) + firstError = new UncheckedIOException(e); + } + } + + if (firstError != null) + throw firstError; + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolvedSecret.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolvedSecret.java new file mode 100644 index 00000000..a94dd23c --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/ResolvedSecret.java @@ -0,0 +1,91 @@ +package org.cachix.secretspec; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + + +/** + * One resolved secret and its provenance. + */ +public final class ResolvedSecret { + + /** + * The inline value, or null for an as_path secret. + */ + @JsonProperty("value") + private String value; + + /** + * The materialized file path, or null for an inline secret. + */ + @JsonProperty("path") + private String path; + + @JsonProperty("as_path") + private boolean asPath; + + @JsonProperty("source") + private String source = ""; + + @JsonProperty("source_provider") + private String sourceProvider; + + /** Returns the usable string: the file path for an {@code as_path} secret, + * otherwise its inline value. A value-less resolution returns null. + */ + public String get() { + return asPath ? path : value; + } + + public ResolvedSecret() { + } + + public String getValue() { + return value; + } + + public String getPath() { + return path; + } + + public boolean isAsPath() { + return asPath; + } + + public String getSource() { + return source; + } + + public String getSourceProvider() { + return sourceProvider; + } + + + @Override + public int hashCode() { + return Objects.hash( + value, + path, + asPath, + source, + sourceProvider + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (ResolvedSecret) o; + return Objects.equals(value, other.value) + && Objects.equals(path, other.path) + && asPath == other.asPath + && Objects.equals(source, other.source) + && Objects.equals(sourceProvider, other.sourceProvider); + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretReport.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretReport.java new file mode 100644 index 00000000..5dfb81a9 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretReport.java @@ -0,0 +1,95 @@ +package org.cachix.secretspec; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + + +/** + * The value-free resolution outcome for one declared secret. + */ +public final class SecretReport { + + @JsonProperty("name") + private String name = ""; + + @JsonProperty("status") + private String status = ""; + + @JsonProperty("required") + private boolean required; + + @JsonProperty("source_provider") + private String sourceProvider; + + @JsonProperty("default_applied") + private boolean defaultApplied; + + @JsonProperty("generated") + private boolean generated; + + @JsonProperty("as_path") + private boolean asPath; + + public SecretReport() { + } + + public String getName() { + return name; + } + + public String getStatus() { + return status; + } + + public boolean isRequired() { + return required; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public boolean isDefaultApplied() { + return defaultApplied; + } + + public boolean isGenerated() { + return generated; + } + + public boolean isAsPath() { + return asPath; + } + + @Override + public int hashCode() { + return Objects.hash( + name, + status, + required, + sourceProvider, + defaultApplied, + generated, + asPath + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null) + return false; + if (getClass() != o.getClass()) + return false; + var other = (SecretReport) o; + return Objects.equals(name, other.name) + && Objects.equals(status, other.status) + && required == other.required + && Objects.equals(sourceProvider, other.sourceProvider) + && defaultApplied == other.defaultApplied + && generated == other.generated + && asPath == other.asPath; + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpec.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpec.java new file mode 100644 index 00000000..3c1d05df --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpec.java @@ -0,0 +1,24 @@ +package org.cachix.secretspec; + + +/** + * Entry point for the SecretSpec JVM SDK. + */ +public class SecretSpec { + + /** + * Starts a fluent resolution builder. + */ + public static SecretSpecBuilder builder() { + return new SecretSpecBuilder(); + } + + /** + * The ABI version reported by the loaded native resolver. + */ + public static String abiVersion() { return Native.abiVersion(); }; + + private SecretSpec() { + // No instances. + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecBuilder.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecBuilder.java new file mode 100644 index 00000000..a0619823 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecBuilder.java @@ -0,0 +1,163 @@ +package org.cachix.secretspec; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Configures a SecretSpec resolution. + */ +public final class SecretSpecBuilder { + + private static final ObjectMapper MAPPER = SecretSpecJsonContext.MAPPER; + + private final ResolveRequest request = new ResolveRequest(); + + public SecretSpecBuilder withPath(String path) { + this.request.setPath(path); + return this; + } + + public SecretSpecBuilder withProvider(String provider) { + this.request.setProvider(provider); + return this; + } + + public SecretSpecBuilder withProfile(String profile) { + this.request.setProfile(profile); + return this; + } + + /** + * Limits resolution to a named manifest scope (SecretSpec 0.17+). + */ + public SecretSpecBuilder withScope(String scope) { + this.request.setScope(scope); + return this; + } + + public SecretSpecBuilder withReason(String reason) { + this.request.setReason(reason); + return this; + } + + public SecretSpecBuilder withNoValues(boolean noValues) { + this.request.setNoValues(noValues); + return this; + } + + public SecretSpecBuilder withNoValues() { + return withNoValues(true); + } + + /** + * Resolves the configured secrets. + * + * @return a {@link Resolved} instance with the secrets loaded + * @throws MissingRequiredException if a required secret was missing + * @throws SecretSpecException if resolution otherwise failed + */ + public Resolved load() { + ResolveResponseContract response = call( + this.request, + "resolve", + SecretSpecJsonContext.RESOLVE_ENVELOPE + ); + + ensureSchemaVersion(response.getSchemaVersion(), JsonContracts.RESOLVE_SCHEMA_VERSION, "resolve"); + + if (response.getMissingRequired() != null && !response.getMissingRequired().isEmpty()) { + throw new MissingRequiredException(response.getMissingRequired()); + } + + return new Resolved( + response.getProvider(), + response.getProfile(), + response.getScope(), + response.getSecrets(), + response.getMissingOptional() + ); + } + + /** + * Resolves a value-free inventory/preflight report. Missing required + * secrets appear in the report rather than throwing. + * + * @return a {@link ResolutionReport} instance + */ + public ResolutionReport report() { + ResolveRequest reportRequest = new ResolveRequest(request); + reportRequest.setMode("report"); + + var response = call( + reportRequest, + "report", + SecretSpecJsonContext.REPORT_ENVELOPE + ); + + ensureSchemaVersion(response.getSchemaVersion(), JsonContracts.REPORT_SCHEMA_VERSION, "report"); + + return new ResolutionReport( + response.getProvider(), + response.getProfile(), + response.getScope(), + response.getSecrets() + ); + } + + private static T call( + ResolveRequest request, + String kind, + TypeReference> typeReference) { + + String payload; + try { + payload = MAPPER.writeValueAsString(request); + } catch (JsonProcessingException e) { + throw new SecretSpecException("serialize", "Failed to serialize request: " + e.getMessage(), e); + } + + String raw = Native.resolve(payload); + + Envelope envelope; + try { + envelope = MAPPER.readValue(raw, typeReference); + } catch (JsonProcessingException error) { + throw new SecretSpecException("parse", error.getMessage(), error); + } + + if (envelope == null) { + throw new SecretSpecException("parse", "native resolver returned an empty response"); + } + + if (!envelope.isOk()) { + String errorKind = (envelope.getError() != null && envelope.getError().getKind() != null) + ? envelope.getError().getKind() + : "unknown"; + String errorMessage = (envelope.getError() != null && envelope.getError().getMessage() != null) + ? envelope.getError().getMessage() + : "native resolver returned an unspecified error"; + + throw new SecretSpecException(errorKind, errorMessage); + } + + if (envelope.getResponse() == null) { + throw new SecretSpecException( + "ffi", + String.format("secretspec_resolve reported ok with no %s response", kind) + ); + } + + return envelope.getResponse(); + } + + private static void ensureSchemaVersion(int actual, int expected, String kind) { + if (actual != expected) { + throw new SecretSpecException( + "version", + String.format("unsupported %s schema version %d (expected %d); " + + "the secretspec-ffi library and this SDK are out of sync", kind, actual, expected) + ); + } + } +} diff --git a/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecException.java b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecException.java new file mode 100644 index 00000000..5ba59ae6 --- /dev/null +++ b/secretspec-jvm/src/main/java/org/cachix/secretspec/SecretSpecException.java @@ -0,0 +1,27 @@ +package org.cachix.secretspec; + + +/** + * A manifest, provider, policy, native-loading, or wire-format failure. + */ +public class SecretSpecException extends RuntimeException { + + public SecretSpecException(String kind, String message) { + super(message + " (kind: " + kind + ")"); + this.kind = kind; + } + + public SecretSpecException(String kind, String message, Throwable cause) { + super(message + " (kind: " + kind + ")", cause); + this.kind = kind; + } + + /** + * A stable machine-readable error category. + */ + private final String kind; + + public String getKind() { + return kind; + } +} diff --git a/secretspec-jvm/src/test/java/org/cachix/secretspec/NativeTest.java b/secretspec-jvm/src/test/java/org/cachix/secretspec/NativeTest.java new file mode 100644 index 00000000..67e2b5c2 --- /dev/null +++ b/secretspec-jvm/src/test/java/org/cachix/secretspec/NativeTest.java @@ -0,0 +1,16 @@ +package org.cachix.secretspec; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + + +class NativeTest { + + @Test + void abi_version_should_be_defined() { + String version = Native.abiVersion(); + + assertNotNull(version); + assertFalse(version.isBlank()); + } +} diff --git a/secretspec-jvm/src/test/java/org/cachix/secretspec/SecretSpecTest.java b/secretspec-jvm/src/test/java/org/cachix/secretspec/SecretSpecTest.java new file mode 100644 index 00000000..c4fdf0f2 --- /dev/null +++ b/secretspec-jvm/src/test/java/org/cachix/secretspec/SecretSpecTest.java @@ -0,0 +1,383 @@ +package org.cachix.secretspec; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + + +class SecretSpecTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String MANIFEST = + "[project]\n" + + "name = \"java-test\"\n" + + "revision = \"1.0\"\n" + + "\n" + + "[profiles.default]\n" + + "DATABASE_URL = { description = \"DB\", required = true }\n" + + "DEV_SESSION_SECRET = { description = \"Development-only session secret\", required = false, default = \"development-only-secret\" }\n" + + "SENTRY_DSN = { description = \"sentry\", required = false }\n" + + "\n" + + "[scopes.database]\n" + + "secrets = [\"DATABASE_URL\"]\n"; + + @Test + void testAbiVersion() { + assertThat(SecretSpec.abiVersion()) + .withFailMessage("ABI version was empty") + .isNotBlank(); + } + + @Test + void testLoad() { + try (Project project = Project.create(MANIFEST, "DATABASE_URL=postgres://db\n"); + Resolved resolved = project.builder().load()) { + + assertThat(resolved.getProfile()).isEqualTo("default"); + assertThat(resolved.getSecrets().get("DATABASE_URL").get()).isEqualTo("postgres://db"); + assertThat(resolved.getSecrets().get("DATABASE_URL").getSource()).isEqualTo("provider"); + assertThat(resolved.getSecrets().get("DATABASE_URL").getSourceProvider()).withFailMessage("provider provenance missing").isNotNull(); + assertThat(resolved.getSecrets().get("DEV_SESSION_SECRET").get()).isEqualTo("development-only-secret"); + assertThat(resolved.getSecrets().get("DEV_SESSION_SECRET").getSource()).isEqualTo("default"); + assertThat(resolved.getMissingOptional()).isEqualTo(List.of("SENTRY_DSN")); + assertThat(resolved.getSecrets()).withFailMessage("missing optional secret was returned").doesNotContainKey("SENTRY_DSN"); + + JsonNode fields = readJson(resolved.fieldsJson()); + assertThat(fields.get("DATABASE_URL").asText()).isEqualTo("postgres://db"); + } + } + + @Test + void testScope() { + try (Project project = Project.create( + MANIFEST, + "DATABASE_URL=postgres://db\nSENTRY_DSN=https://sentry\n")) { + + SecretSpecBuilder builder = project.builder().withScope("database"); + + try (Resolved resolved = builder.load()) { + assertThat(resolved.getScope()).isEqualTo("database"); + assertThat(resolved.getSecrets().keySet()).containsExactly("DATABASE_URL"); + } + + ResolutionReport report = builder.report(); + assertThat(report.getScope()).isEqualTo("database"); + List reportNames = report.getSecrets().stream() + .map(SecretReport::getName) + .collect(Collectors.toList()); + assertThat(reportNames).containsExactly("DATABASE_URL"); + } + } + + @Test + void testMissingRequired() { + try (Project project = Project.create(MANIFEST, "")) { + MissingRequiredException error = catchThrowableOfType(MissingRequiredException.class, () -> project.builder().load()); + assertThat(error.getMissing()).containsExactly("DATABASE_URL"); + assertThat(error.getKind()).isEqualTo("missing_required"); + } + } + + @Test + void testInvalidManifest() { + Path invalidPath = Path.of(System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString(), "secretspec.toml"); + SecretSpecException error = catchThrowableOfType(SecretSpecException.class, () -> + SecretSpec.builder() + .withPath(invalidPath.toString()) + .withReason("Java test") + .load() + ); + + assertThat(error.getClass()).withFailMessage("transport failure became missing-required") + .isNotEqualTo(MissingRequiredException.class); + assertThat(error.getKind()).withFailMessage("error kind was empty") + .isNotNull() + .isNotBlank(); + } + + @Test + void testAsPathCleanup() { + String manifest = + "[project]\n" + + "name = \"java-test\"\n" + + "revision = \"1.0\"\n" + + "\n" + + "[profiles.default]\n" + + "TLS_CERT = { description = \"cert\", required = true, as_path = true }\n"; + + String path; + try (Project project = Project.create(manifest, "TLS_CERT=----cert----\n")) { + try (Resolved resolved = project.builder().load()) { + ResolvedSecret cert = resolved.getSecrets().get("TLS_CERT"); + assertThat(cert.isAsPath()).withFailMessage("TLS_CERT was not marked as_path").isTrue(); + assertThat(cert.getValue()).withFailMessage("as_path secret exposed an inline value").isNull(); + + path = cert.get(); + assertThat(path).withFailMessage("as_path secret had no path").isNotNull(); + assertThat(readString(Path.of(path))).isEqualTo("----cert----"); + } + assertThat(Path.of(path)).withFailMessage("Dispose/close did not remove the secret temp file") + .doesNotExist(); + } + } + + @Test + void testReport() { + try (Project project = Project.create(MANIFEST, "")) { + ResolutionReport report = project.builder().report(); + + assertThat(report.getProfile()).isEqualTo("default"); + + SecretReport database = report.getSecrets().stream() + .filter(s -> "DATABASE_URL".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("DATABASE_URL not found")); + assertThat(database.getStatus()).isEqualTo("missing_required"); + assertThat(database.isRequired()).withFailMessage("DATABASE_URL was not reported as required").isTrue(); + + SecretReport sessionSecret = report.getSecrets().stream() + .filter(s -> "DEV_SESSION_SECRET".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("DEV_SESSION_SECRET not found")); + assertThat(sessionSecret.isDefaultApplied()).withFailMessage("DEV_SESSION_SECRET default was not reported").isTrue(); + } + } + + @Test + void testSetAsSystemProperties() { + var previous = System.getProperty("DATABASE_URL"); + try (Project project = Project.create(MANIFEST, "DATABASE_URL=postgres://environment\n")) { + try (Resolved resolved = project.builder().load()) { + resolved.setAsSystemProperties(); + assertThat(System.getProperty("DATABASE_URL")) + .isNotEqualTo(previous) + .isEqualTo("postgres://environment"); + } + finally { + if (previous != null) { + System.setProperty("DATABASE_URL", previous); + } + } + } + } + + @Test void testConformance() throws IOException { + Path root = findRepositoryRoot(); + Path fixtures = root.resolve("conformance").resolve("fixtures"); + + List directories; + try (Stream stream = Files.list(fixtures)) { + directories = stream.filter(Files::isDirectory) + .sorted(Comparator.comparing(Path::getFileName)) + .collect(Collectors.toList()); + } + + assertThat(directories.size()) + .withFailMessage("Error: No conformance tests found!") + .isGreaterThan(0); + + for (Path directory : directories) { + String manifest = directory.resolve("secretspec.toml").toString(); + String provider = "dotenv://" + directory.resolve(".env"); + + SecretSpecBuilder fixture = SecretSpec.builder() + .withPath(manifest) + .withProvider(provider) + .withReason("conformance"); + + try (Resolved resolved = fixture.load()) { + assertJsonEqual( + readString(directory.resolve("expected.json")), + MAPPER.writeValueAsString(canonicalResolved(resolved)) + ); + } + + try (Resolved noValues = fixture.withNoValues(true).load()) { + + System.out.println(noValues); + + assertJsonEqual( + readString(directory.resolve("expected_no_values.json")), + noValues.fieldsJson() + ); + } + + ResolutionReport report = fixture.report(); + assertJsonEqual( + readString(directory.resolve("expected_report.json")), + MAPPER.writeValueAsString(canonicalReport(report)) + ); + } + } + + private static ObjectNode canonicalResolved(Resolved resolved) { + ObjectNode secrets = MAPPER.createObjectNode(); + + for (Map.Entry entry : resolved.getSecrets().entrySet()) { + String name = entry.getKey(); + ResolvedSecret secret = entry.getValue(); + + String value = secret.isAsPath() + ? readString(Path.of(Objects.requireNonNull(secret.get(), name + " had no path"))) + : secret.getValue(); + + ObjectNode secretNode = MAPPER.createObjectNode(); + secretNode.put("value", value); + secretNode.put("source", secret.getSource()); + secretNode.put("as_path", secret.isAsPath()); + + secrets.set(name, secretNode); + } + + ObjectNode root = MAPPER.createObjectNode(); + root.put("profile", resolved.getProfile()); + root.set("secrets", secrets); + root.set("missing_required", MAPPER.createArrayNode()); + + ArrayNode missingOptional = MAPPER.createArrayNode(); + for (String item : resolved.getMissingOptional()) { + missingOptional.add(item); + } + root.set("missing_optional", missingOptional); + + return root; + } + + private static ObjectNode canonicalReport(ResolutionReport report) { + ObjectNode secrets = MAPPER.createObjectNode(); + + for (SecretReport secret : report.getSecrets()) { + ObjectNode node = MAPPER.createObjectNode(); + node.put("status", secret.getStatus()); + node.put("required", secret.isRequired()); + node.put("as_path", secret.isAsPath()); + node.put("generated", secret.isGenerated()); + node.put("default_applied", secret.isDefaultApplied()); + node.put("source_provider", secret.getSourceProvider() != null); + + secrets.set(secret.getName(), node); + } + + ObjectNode root = MAPPER.createObjectNode(); + root.put("profile", report.getProfile()); + root.set("secrets", secrets); + + return root; + } + + private static Path findRepositoryRoot() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (current != null) { + if (Files.exists(current.resolve("Cargo.toml")) && Files.exists(current.resolve("conformance"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("could not find the SecretSpec repository root"); + } + + private static void assertJsonEqual(String expected, String actual) { + JsonNode expectedNode = readJson(expected); + JsonNode actualNode = readJson(actual); + assertThat(actualNode) + .withFailMessage(String.format("JSON mismatch%nactual: %s%nexpected: %s", actual, expected)) + .isEqualTo(expectedNode); + } + + // Java 11 IO Compatibility + private static String readString(Path path) { + try { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // Java 11 IO Compatibility + private static void writeString(Path path, String content) { + try { + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static JsonNode readJson(String text) { + try { + return MAPPER.readTree(text); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static final class Project implements AutoCloseable { + private final Path root; + final String manifestPath; + final String provider; + + private Project(Path root) { + this.root = root; + this.manifestPath = root.resolve("secretspec.toml").toString(); + this.provider = "dotenv://" + root.resolve(".env"); + } + + SecretSpecBuilder builder() { + return SecretSpec.builder() + .withPath(manifestPath) + .withProvider(provider) + .withReason("Java test"); + } + + static Project create(String manifest, String dotenv) { + try { + Path tempDir = Files.createTempDirectory("secretspec-jvm-"); + Project project = new Project(tempDir); + writeString(Path.of(project.manifestPath), manifest); + writeString(tempDir.resolve(".env"), dotenv); + return project; + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void close() { + deleteDirectory(root.toFile()); + } + + private static void deleteDirectory(File dir) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + if (f.isDirectory()) deleteDirectory(f); + else f.delete(); + } + } + dir.delete(); + } + } +}