diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index ec23b6e40..c1f85f30f 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -1,6 +1,6 @@ name: ".NET SDK" -# Builds the C# client and the runtime-specific secretspec-ffi libraries packed +# Builds the C# client and the runtime-specific libsecretspec libraries packed # into Cachix.SecretSpec. Glibc Linux uses a manylinux_2_28 baseline; Alpine # receives separate musl assets. @@ -25,7 +25,7 @@ on: pull_request: paths: - "secretspec-dotnet/**" - - "secretspec-ffi/**" + - "libsecretspec/**" - ".github/workflows/dotnet-package.yml" - "scripts/install-rustup.sh" - "scripts/sync-sdk-versions.sh" @@ -46,35 +46,35 @@ jobs: 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 + library: libsecretspec.so rustflags: -C strip=symbols - rid: linux-arm64 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 + library: libsecretspec.so rustflags: -C strip=symbols - rid: osx-x64 target: x86_64-apple-darwin runner: macos-15-intel - library: libsecretspec_ffi.dylib + library: libsecretspec.dylib deployment_target: "12.0" rustflags: -C strip=symbols - rid: osx-arm64 target: aarch64-apple-darwin runner: macos-latest - library: libsecretspec_ffi.dylib + library: libsecretspec.dylib deployment_target: "12.0" rustflags: -C strip=symbols - rid: win-x64 target: x86_64-pc-windows-msvc runner: windows-latest - library: secretspec_ffi.dll + library: secretspec.dll rustflags: -C strip=symbols -C target-feature=+crt-static - rid: win-arm64 target: aarch64-pc-windows-msvc runner: windows-11-arm - library: secretspec_ffi.dll + library: secretspec.dll rustflags: -C strip=symbols -C target-feature=+crt-static steps: @@ -110,7 +110,7 @@ jobs: MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment_target }} RUSTFLAGS: ${{ matrix.rustflags }} run: >- - cargo build -p secretspec-ffi --release + cargo build -p libsecretspec --release --target ${{ matrix.target }} - name: Verify glibc portability (glibc <= 2.28, no libdbus) @@ -146,10 +146,18 @@ jobs: - name: Stage native NuGet asset shell: bash + # Ship the Windows cdylib as libsecretspec.dll. Cargo emits + # secretspec.dll there (MSVC drops the lib prefix), which is the same + # filename as the managed SecretSpec.dll on a case-insensitive + # filesystem, so the two collide in a consumer's output directory. run: | mkdir -p "staged/${{ matrix.rid }}/native" + packaged="${{ matrix.library }}" + if [ "$packaged" = "secretspec.dll" ]; then + packaged="libsecretspec.dll" + fi cp "target/${{ matrix.target }}/release/${{ matrix.library }}" \ - "staged/${{ matrix.rid }}/native/" + "staged/${{ matrix.rid }}/native/$packaged" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -192,15 +200,15 @@ jobs: bash scripts/install-rustup.sh export PATH="$HOME/.cargo/bin:$PATH" rustup toolchain install - cargo build -p secretspec-ffi --release \ + cargo build -p libsecretspec --release \ --target "${{ matrix.target }}" ' test -f \ - "target/${{ matrix.target }}/release/libsecretspec_ffi.so" + "target/${{ matrix.target }}/release/libsecretspec.so" - name: Verify musl portability shell: bash run: | - library="target/${{ matrix.target }}/release/libsecretspec_ffi.so" + library="target/${{ matrix.target }}/release/libsecretspec.so" dynamic="$(readelf -d "$library")" needed="$(grep NEEDED <<<"$dynamic")" case "${{ matrix.target }}" in @@ -227,7 +235,7 @@ jobs: - name: Stage native NuGet asset run: | mkdir -p "staged/${{ matrix.rid }}/native" - cp "target/${{ matrix.target }}/release/libsecretspec_ffi.so" \ + cp "target/${{ matrix.target }}/release/libsecretspec.so" \ "staged/${{ matrix.rid }}/native/" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/ffi-build.yml b/.github/workflows/ffi-build.yml index d20e6a389..1e3be17c1 100644 --- a/.github/workflows/ffi-build.yml +++ b/.github/workflows/ffi-build.yml @@ -1,6 +1,6 @@ name: "FFI cdylib" -# Builds the secretspec-ffi C ABI library for each platform the language SDKs +# Builds the libsecretspec C ABI library for each platform the language SDKs # bundle. Linux builds use a manylinux_2_28 baseline. Other targets build # natively on per-platform runners rather than cross-compiling. @@ -27,7 +27,7 @@ on: # sdks.yml; the full matrix here still runs on tags and manual dispatch. pull_request: paths: - - "secretspec-ffi/**" + - "libsecretspec/**" - ".github/workflows/ffi-build.yml" - "scripts/check-linux-portability.sh" - "scripts/install-rustup.sh" @@ -46,17 +46,17 @@ jobs: - target: x86_64-unknown-linux-gnu runner: ubuntu-latest container: quay.io/pypa/manylinux_2_28_x86_64:2026.08.05-1@sha256:e0b40ace8e818e96026eb47714b01998cbca022a6995797d0905474ce3e82ae8 - artifact: libsecretspec_ffi.so + artifact: libsecretspec.so - target: aarch64-unknown-linux-gnu runner: ubuntu-24.04-arm container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.05-1@sha256:f766b402889e40f439e7a3ee5788eef1aa3ef399d0110107d27419fa2ba9d905 - artifact: libsecretspec_ffi.so + artifact: libsecretspec.so - target: aarch64-apple-darwin runner: macos-latest - artifact: libsecretspec_ffi.dylib + artifact: libsecretspec.dylib - target: x86_64-pc-windows-msvc runner: windows-latest - artifact: secretspec_ffi.dll + artifact: secretspec.dll steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -74,7 +74,7 @@ jobs: - name: Build cdylib run: >- - cargo build -p secretspec-ffi --release + cargo build -p libsecretspec --release --target ${{ matrix.target }} - name: Verify Linux portability (glibc <= 2.28, no libdbus) @@ -87,10 +87,10 @@ jobs: - name: Smoke test the C ABI (Unix) if: runner.os != 'Windows' run: | - cc secretspec-ffi/tests/smoke.c \ - -I secretspec-ffi/include \ + cc libsecretspec/tests/smoke.c \ + -I libsecretspec/include \ -L target/${{ matrix.target }}/release \ - -lsecretspec_ffi -o smoke + -lsecretspec -o smoke LD_LIBRARY_PATH=target/${{ matrix.target }}/release \ DYLD_LIBRARY_PATH=target/${{ matrix.target }}/release \ ./smoke @@ -100,17 +100,17 @@ jobs: shell: bash run: | ext="${{ matrix.artifact }}"; ext="${ext##*.}" - asset="secretspec-ffi-${{ matrix.target }}.${ext}" + asset="libsecretspec-${{ matrix.target }}.${ext}" cp "target/${{ matrix.target }}/release/${{ matrix.artifact }}" "$asset" echo "asset=$asset" >> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: secretspec-ffi-${{ matrix.target }} + name: libsecretspec-${{ matrix.target }} path: | ${{ steps.stage.outputs.asset }} - secretspec-ffi/include/secretspec.h + libsecretspec/include/secretspec.h release: name: Publish FFI release @@ -126,7 +126,7 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: secretspec-ffi-* + pattern: libsecretspec-* path: staged merge-multiple: true @@ -142,7 +142,7 @@ jobs: run: | set -euo pipefail mapfile -t assets < <( - find staged -maxdepth 1 -type f -name 'secretspec-ffi-*' -print | + find staged -maxdepth 1 -type f -name 'libsecretspec-*' -print | sort ) if [[ "${#assets[@]}" -ne 4 ]]; then diff --git a/.github/workflows/go-embed.yml b/.github/workflows/go-embed.yml index 24d56ec60..e6794c4bf 100644 --- a/.github/workflows/go-embed.yml +++ b/.github/workflows/go-embed.yml @@ -33,7 +33,7 @@ on: pull_request: paths: - "secretspec-go/**" - - "secretspec-ffi/**" + - "libsecretspec/**" - ".github/workflows/go-embed.yml" - "scripts/check-linux-portability.sh" - "scripts/install-rustup.sh" @@ -85,7 +85,7 @@ jobs: shell: bash run: >- bash scripts/check-linux-portability.sh - "secretspec-go/lib/secretspec_ffi_${{ matrix.target }}.so" + "secretspec-go/lib/secretspec_${{ matrix.target }}.so" - name: Build and smoke test the embedded SDK (no SECRETSPEC_FFI_LIB) shell: bash @@ -154,7 +154,7 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} run: | set -euo pipefail - mapfile -t assets < <(find staged -type f -name 'secretspec_ffi_*' -print | sort) + mapfile -t assets < <(find staged -type f -name 'secretspec_*' -print | sort) if [[ "${#assets[@]}" -ne 4 ]]; then printf 'expected 4 embedded libraries, found %s\n' "${#assets[@]}" >&2 printf '%s\n' "${assets[@]}" >&2 diff --git a/.github/workflows/go-static.yml b/.github/workflows/go-static.yml index d9361985c..a1e7e2cc1 100644 --- a/.github/workflows/go-static.yml +++ b/.github/workflows/go-static.yml @@ -1,7 +1,7 @@ name: "Go static lib" # Builds the fully-static (musl) Go binary for the `-tags static` binding: cgo -# links libsecretspec_ffi.a directly into the executable, so the Rust resolver is +# links libsecretspec.a directly into the executable, so the Rust resolver is # embedded and the binary has no dynamic dependencies at all. Built via devenv, # which provides the musl C cross-toolchain (for the sqlite3/aws-lc-sys build # scripts and the cgo link) and static libunwind. @@ -34,7 +34,7 @@ on: pull_request: paths: - "secretspec-go/**" - - "secretspec-ffi/**" + - "libsecretspec/**" - ".github/workflows/go-static.yml" permissions: diff --git a/.github/workflows/haskell-build.yml b/.github/workflows/haskell-build.yml index 525e5cfab..11ad9a7d7 100644 --- a/.github/workflows/haskell-build.yml +++ b/.github/workflows/haskell-build.yml @@ -1,7 +1,7 @@ name: "Haskell SDK" # Builds and tests the Haskell SDK (secretspec-hs) against a freshly built -# secretspec-ffi staticlib. The SDK statically links the C ABI archive at build +# libsecretspec staticlib. The SDK statically links the C ABI archive at build # time, so the Rust resolver is embedded in the binary and there is no runtime # loader path (no LD_LIBRARY_PATH). # @@ -25,7 +25,7 @@ on: pull_request: paths: - "secretspec-hs/**" - - "secretspec-ffi/**" + - "libsecretspec/**" - "secretspec/**" - ".github/workflows/haskell-build.yml" - "scripts/sync-sdk-versions.sh" @@ -66,17 +66,17 @@ jobs: run: | devenv shell -- bash -c ' set -euo pipefail - cargo build -p secretspec-ffi -p secretspec + cargo build -p libsecretspec -p secretspec target_dir="$(cargo metadata --no-deps --format-version 1 \ | grep -o "\"target_directory\":\"[^\"]*\"" | head -1 | sed "s/.*:\"\(.*\)\"/\1/")" export SECRETSPEC_BIN="$target_dir/debug/secretspec" # Capture the staticlib archive plus its transitive native deps. Stage - # the .a alone so -lsecretspec_ffi resolves to the archive (target/debug + # the .a alone so -lsecretspec resolves to the archive (target/debug # also holds the .so) and the resolver is embedded with no loader path. - native_libs="$(cargo rustc -q -p secretspec-ffi --crate-type staticlib -- \ + native_libs="$(cargo rustc -q -p libsecretspec --crate-type staticlib -- \ --print native-static-libs 2>&1 | sed -n "s/^note: native-static-libs: //p" | tail -1)" hs_lib_dir="$(mktemp -d)" - cp "$target_dir/debug/libsecretspec_ffi.a" "$hs_lib_dir/" + cp "$target_dir/debug/libsecretspec.a" "$hs_lib_dir/" cd secretspec-hs cabal update # --write-ghc-environment-files lets the codegen test compile the @@ -88,9 +88,9 @@ jobs: --ghc-options="-optl${native_libs// / -optl}" \ --write-ghc-environment-files=always --test-show-details=streaming ffi_prefix="$(mktemp -d)" - bash ../secretspec-ffi/scripts/cinstall.sh "$ffi_prefix" static + bash ../libsecretspec/scripts/cinstall.sh "$ffi_prefix" static PKG_CONFIG_PATH="$ffi_prefix/lib/pkgconfig" \ - pkg-config --print-errors --exists secretspec_ffi + pkg-config --print-errors --exists libsecretspec PKG_CONFIG_PATH="$ffi_prefix/lib/pkgconfig" cabal test -f use-pkg-config \ --write-ghc-environment-files=always --test-show-details=streaming ' @@ -130,20 +130,20 @@ jobs: # crate's list so the unused cdylib is never linked. Release like # the shipped artifacts: a debug archive carries sectionless .dwo # members that objcopy below refuses to process. - cargo rustc -p secretspec-ffi --release --target x86_64-pc-windows-gnu \ + cargo rustc -p libsecretspec --release --target x86_64-pc-windows-gnu \ --crate-type staticlib # The CLI (for the test-suite's end-to-end codegen test) builds for # the default MSVC host target, same as the released binaries. cargo build -p secretspec export SECRETSPEC_BIN="$(cygpath -w "$PWD/target/debug/secretspec.exe")" - native_libs="$(cargo rustc -q -p secretspec-ffi --release \ + native_libs="$(cargo rustc -q -p libsecretspec --release \ --target x86_64-pc-windows-gnu --crate-type staticlib -- \ --print native-static-libs 2>&1 \ | sed -n 's/^note: native-static-libs: //p' | tail -1)" # Stage the .a alone (target/ also holds rlibs) plus the import # libraries that ship inside cargo registry crates (see header). hs_lib_dir="$(mktemp -d)" - cp target/x86_64-pc-windows-gnu/release/libsecretspec_ffi.a "$hs_lib_dir/" + cp target/x86_64-pc-windows-gnu/release/libsecretspec.a "$hs_lib_dir/" # Two archive fixups for GHC's older bundled toolchain, which links # the final test binary: # * rustc's windows-gnu objects carry `-exclude-symbols` .drectve @@ -160,11 +160,11 @@ jobs: # objcopy refuses archives holding sectionless split-debuginfo # members (the prebuilt std ships .dwo members a linker never # selects), so drop those first. - ar t "$hs_lib_dir/libsecretspec_ffi.a" | grep '\.dwo$' | while read -r m; do - ar d "$hs_lib_dir/libsecretspec_ffi.a" "$m" + ar t "$hs_lib_dir/libsecretspec.a" | grep '\.dwo$' | while read -r m; do + ar d "$hs_lib_dir/libsecretspec.a" "$m" done objcopy --remove-section=.drectve --redefine-sym nanosleep64=nanosleep \ - "$hs_lib_dir/libsecretspec_ffi.a" + "$hs_lib_dir/libsecretspec.a" printf '%s\n' "$native_libs" > "$hs_lib_dir/native-static-libs.txt" bash scripts/copy-mingw-import-libs.sh \ "$hs_lib_dir/native-static-libs.txt" "$hs_lib_dir" @@ -211,7 +211,7 @@ jobs: run: nix profile install nixpkgs#devenv - name: sdist and upload to Hackage # Requires the HACKAGE_TOKEN secret. The package statically links - # secretspec-ffi at build time, so Hackage's build bots cannot compile it + # libsecretspec at build time, so Hackage's build bots cannot compile it # (no staticlib, no Rust toolchain); the upload still succeeds and the # README documents the link requirement. env: diff --git a/.github/workflows/ruby-gems.yml b/.github/workflows/ruby-gems.yml index 25af62251..2faba33e7 100644 --- a/.github/workflows/ruby-gems.yml +++ b/.github/workflows/ruby-gems.yml @@ -1,7 +1,7 @@ name: "Ruby gems" # Builds platform-specific gems for the Ruby SDK. Each gem bundles the -# secretspec-ffi staticlib (into vendor/); at `gem install` mkmf compiles a tiny +# libsecretspec staticlib (into vendor/); at `gem install` mkmf compiles a tiny # C glue and statically links that archive, so the resolver is embedded in the # extension and one platform gem serves every Ruby ABI. # @@ -108,7 +108,7 @@ jobs: run: | cd secretspec-rb # Installing compiles the extension (spec.extensions) against the - # bundled vendor/libsecretspec_ffi.a -- no Rust toolchain needed here. + # bundled vendor/libsecretspec.a -- no Rust toolchain needed here. gem install --no-document --install-dir "$RUNNER_TEMP/gemhome" secretspec-*.gem # Run outside the repo so only the installed extension satisfies require. cd "$RUNNER_TEMP" diff --git a/.github/workflows/sdks.yml b/.github/workflows/sdks.yml index 1944d99c4..208651ad9 100644 --- a/.github/workflows/sdks.yml +++ b/.github/workflows/sdks.yml @@ -66,12 +66,12 @@ jobs: - name: Build the native resolver env: MACOSX_DEPLOYMENT_TARGET: "12.0" - run: cargo build -p secretspec-ffi + run: cargo build -p libsecretspec - name: Build the local XCFramework run: >- bash scripts/build-swift-xcframework.sh secretspec-swift/Artifacts/CSecretSpec.xcframework - target/debug/libsecretspec_ffi.dylib + target/debug/libsecretspec.dylib - name: Run the Swift SDK and conformance tests run: | swift build --target SecretSpecExamples diff --git a/.github/workflows/swift-package.yml b/.github/workflows/swift-package.yml index 807bacfce..212e71fe3 100644 --- a/.github/workflows/swift-package.yml +++ b/.github/workflows/swift-package.yml @@ -37,7 +37,7 @@ on: paths: - "Package.swift" - "secretspec-swift/**" - - "secretspec-ffi/**" + - "libsecretspec/**" - "scripts/build-swift-xcframework.sh" - "scripts/sync-sdk-versions.sh" - ".github/workflows/swift-package.yml" @@ -63,16 +63,16 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "12.0" RUSTFLAGS: "-C strip=symbols" run: | - cargo build -p secretspec-ffi --release \ + cargo build -p libsecretspec --release \ --target aarch64-apple-darwin - cargo build -p secretspec-ffi --release \ + cargo build -p libsecretspec --release \ --target x86_64-apple-darwin - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: swift-native-macos path: | - target/aarch64-apple-darwin/release/libsecretspec_ffi.dylib - target/x86_64-apple-darwin/release/libsecretspec_ffi.dylib + target/aarch64-apple-darwin/release/libsecretspec.dylib + target/x86_64-apple-darwin/release/libsecretspec.dylib package: name: XCFramework and Swift tests @@ -97,7 +97,7 @@ jobs: libraries=() while IFS= read -r library; do libraries+=("$library") - done < <(find staged -type f -name libsecretspec_ffi.dylib -print | sort) + done < <(find staged -type f -name libsecretspec.dylib -print | sort) if [[ "${#libraries[@]}" -ne 2 ]]; then printf 'expected 2 native libraries, found %s\n' "${#libraries[@]}" >&2 printf '%s\n' "${libraries[@]}" >&2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5007d3304..c7131626a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,6 +118,12 @@ jobs: uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: key: ${{ matrix.os }} + # libsecretspec-resolver takes yyjson from the system rather than vendoring + # it, and this job has no Nix to supply it. The Rust conformance package + # compiles the same C sources, so this has to precede every cargo step too. + - name: Install yyjson + shell: bash + run: bash scripts/install-yyjson.sh # Formatting and linting are platform-independent; run them once on Linux. # The PHP extension is checked in the SDK workflow, where PHP headers are @@ -130,6 +136,28 @@ jobs: run: cargo clippy --workspace --exclude secretspec-php-native - name: Run tests run: cargo test --workspace --exclude secretspec-php-native + # The C client is standalone C11, so it is built with its own build systems + # rather than the devenv shell: this job installs Rust directly and has no + # Nix, and Windows runners cannot run devenv at all. + - name: Install Meson and Ninja + shell: bash + # pipx rather than pip: the macOS runner's Python is externally managed + # (PEP 668), so a plain --user install is refused there. + run: | + pipx install meson + pipx install ninja + - name: Build standalone C IPC client with Meson + shell: bash + run: | + meson setup libsecretspec-resolver/build-meson libsecretspec-resolver + meson compile -C libsecretspec-resolver/build-meson + meson test -C libsecretspec-resolver/build-meson --print-errorlogs + - name: Build standalone C IPC client with CMake + shell: bash + run: | + cmake -S libsecretspec-resolver -B libsecretspec-resolver/build-cmake + cmake --build libsecretspec-resolver/build-cmake + ctest --test-dir libsecretspec-resolver/build-cmake --output-on-failure # Each optional provider must compile on its own, not just in the default # feature set. `cargo test` above cannot catch a missing feature gate: it @@ -207,9 +235,26 @@ jobs: run: rustup toolchain install stable --profile minimal - name: Cache Rust builds uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # This runner has no pkg-config, so the script also exports the explicit + # YYJSON_INCLUDE_DIR / YYJSON_LIB_DIR pair that the conformance build script + # falls back to. + - name: Install yyjson + shell: bash + run: bash scripts/install-yyjson.sh - name: cargo test # Exclude secretspec-php-native: it is an ext-php-rs extension that needs a # PHP dev toolchain (php-config/headers) this bare runner does not have, and # ext-php-rs rejects the runner's PHP version anyway. The PHP SDK is built # and tested by sdks.yml / php-ext.yml instead. run: cargo test --workspace --exclude secretspec-php-native + # The Rust conformance build compiles the Windows C launcher, but only the + # standalone suite executes its process/environment regressions. Keep this + # native CMake run on Windows so ACL-adjacent launch behavior cannot be + # "covered" solely by Linux and macOS tests of the POSIX implementation. + - name: Build and test standalone C IPC client + shell: pwsh + run: | + cmake -S libsecretspec-resolver -B libsecretspec-resolver/build-cmake + cmake --build libsecretspec-resolver/build-cmake --config Release + ctest --test-dir libsecretspec-resolver/build-cmake ` + --build-config Release --output-on-failure diff --git a/.github/workflows/windows-startup.yml b/.github/workflows/windows-startup.yml new file mode 100644 index 000000000..3f4b69bbf --- /dev/null +++ b/.github/workflows/windows-startup.yml @@ -0,0 +1,243 @@ +name: "Windows CLI startup diagnostics" + +on: + workflow_dispatch: + inputs: + baseline_tag: + description: "Older release used as the startup baseline" + required: true + default: "v0.19.1" + type: string + comparison_tag: + description: "Newer release to compare with the baseline" + required: true + default: "v0.20.0" + type: string + iterations: + description: "Warm launches measured for each executable" + required: true + default: "50" + type: string + +permissions: + contents: read + +jobs: + diagnose: + name: Compare Windows CLI startup + runs-on: windows-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASELINE_TAG: ${{ inputs.baseline_tag || 'v0.19.1' }} + COMPARISON_TAG: ${{ inputs.comparison_tag || 'v0.20.0' }} + ITERATIONS: ${{ inputs.iterations || '50' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust (pinned by rust-toolchain.toml) + run: rustup toolchain install --profile minimal + + - name: Cache Rust build + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + key: windows-startup + + - name: Build the checked-out CLI + run: cargo build --package secretspec --bin secretspec --profile dist + + - name: Download comparison releases + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $asset = "secretspec-x86_64-pc-windows-msvc.zip" + $releases = Join-Path $env:RUNNER_TEMP "secretspec-startup-releases" + $baseline = Join-Path $releases "baseline" + $comparison = Join-Path $releases "comparison" + New-Item -ItemType Directory -Force $baseline, $comparison | Out-Null + + gh release download $env:BASELINE_TAG ` + --repo cachix/secretspec ` + --pattern $asset ` + --dir $baseline + gh release download $env:COMPARISON_TAG ` + --repo cachix/secretspec ` + --pattern $asset ` + --dir $comparison + + Expand-Archive -LiteralPath (Join-Path $baseline $asset) ` + -DestinationPath (Join-Path $baseline "expanded") + Expand-Archive -LiteralPath (Join-Path $comparison $asset) ` + -DestinationPath (Join-Path $comparison "expanded") + + - name: Benchmark process startup + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $iterations = 0 + if (-not [int]::TryParse($env:ITERATIONS, [ref]$iterations) -or $iterations -lt 5 -or $iterations -gt 500) { + throw "iterations must be an integer between 5 and 500" + } + + # Completion mode can inspect configuration and PATH. It is not part + # of ordinary startup and must not leak in from the runner image. + Remove-Item Env:SECRETSPEC_COMPLETE -ErrorAction SilentlyContinue + + $runDirectory = Join-Path $env:RUNNER_TEMP "secretspec-startup-empty" + New-Item -ItemType Directory -Force $runDirectory | Out-Null + $releaseDirectory = Join-Path $env:RUNNER_TEMP "secretspec-startup-releases" + + $candidates = @( + [pscustomobject]@{ + Name = "baseline ($env:BASELINE_TAG)" + Path = Join-Path $releaseDirectory "baseline/expanded/secretspec.exe" + Samples = [System.Collections.Generic.List[double]]::new() + }, + [pscustomobject]@{ + Name = "comparison ($env:COMPARISON_TAG)" + Path = Join-Path $releaseDirectory "comparison/expanded/secretspec.exe" + Samples = [System.Collections.Generic.List[double]]::new() + }, + [pscustomobject]@{ + Name = "checked-out branch" + Path = Join-Path $env:GITHUB_WORKSPACE "target/dist/secretspec.exe" + Samples = [System.Collections.Generic.List[double]]::new() + } + ) + + # GitHub CLI downloads normally have no Mark-of-the-Web stream, but + # preserve and report it when the runner does attach one. In that + # case, benchmark an unblocked copy too so quarantine overhead is + # distinguishable from normal process startup. + foreach ($candidate in @($candidates[0..1])) { + $zone = Get-Item -LiteralPath $candidate.Path -Stream Zone.Identifier ` + -ErrorAction SilentlyContinue + if ($null -ne $zone) { + $hashPrefix = (Get-FileHash ` + -LiteralPath $candidate.Path ` + -Algorithm SHA256).Hash.Substring(0, 12) + $unblockedPath = Join-Path $runDirectory ` + "${hashPrefix}-unblocked.exe" + Copy-Item -LiteralPath $candidate.Path -Destination $unblockedPath + Unblock-File -LiteralPath $unblockedPath + $candidates += [pscustomobject]@{ + Name = "$($candidate.Name), unblocked" + Path = $unblockedPath + Samples = [System.Collections.Generic.List[double]]::new() + } + } + } + + function Measure-Launch([string]$executable) { + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $executable + $startInfo.ArgumentList.Add("--version") + $startInfo.WorkingDirectory = $runDirectory + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $process = [System.Diagnostics.Process]::Start($startInfo) + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + $timer.Stop() + + if ($process.ExitCode -ne 0) { + throw "$executable exited with $($process.ExitCode): $stderr" + } + [pscustomobject]@{ + Milliseconds = $timer.Elapsed.TotalMilliseconds + Version = $stdout.Trim() + } + } + + foreach ($candidate in $candidates) { + if (-not (Test-Path -LiteralPath $candidate.Path -PathType Leaf)) { + throw "missing executable: $($candidate.Path)" + } + $first = Measure-Launch $candidate.Path + $candidate | Add-Member FirstMilliseconds $first.Milliseconds + $candidate | Add-Member Version $first.Version + } + + # Rotate the order on every iteration so runner load over time does + # not consistently favor one executable. + for ($iteration = 0; $iteration -lt $iterations; $iteration++) { + for ($offset = 0; $offset -lt $candidates.Count; $offset++) { + $candidate = $candidates[($iteration + $offset) % $candidates.Count] + $measurement = Measure-Launch $candidate.Path + $candidate.Samples.Add($measurement.Milliseconds) + } + } + + $results = foreach ($candidate in $candidates) { + $sorted = @($candidate.Samples | Sort-Object) + $medianIndex = [Math]::Floor(($sorted.Count - 1) / 2) + $p95Index = [Math]::Ceiling($sorted.Count * 0.95) - 1 + $file = Get-Item -LiteralPath $candidate.Path + $signature = Get-AuthenticodeSignature -LiteralPath $candidate.Path + $zone = Get-Item -LiteralPath $candidate.Path -Stream Zone.Identifier ` + -ErrorAction SilentlyContinue + + [pscustomobject]@{ + name = $candidate.Name + version = $candidate.Version + bytes = $file.Length + sha256 = (Get-FileHash -LiteralPath $candidate.Path -Algorithm SHA256).Hash.ToLowerInvariant() + signature = $signature.Status.ToString() + mark_of_the_web = $null -ne $zone + first_ms = [Math]::Round($candidate.FirstMilliseconds, 3) + median_ms = [Math]::Round($sorted[$medianIndex], 3) + p95_ms = [Math]::Round($sorted[$p95Index], 3) + minimum_ms = [Math]::Round($sorted[0], 3) + maximum_ms = [Math]::Round($sorted[-1], 3) + iterations = $iterations + } + } + + $report = [pscustomobject]@{ + schema_version = 1 + generated_at = [DateTimeOffset]::UtcNow.ToString("o") + git_sha = $env:GITHUB_SHA + runner = [pscustomobject]@{ + image_os = $env:ImageOS + image_version = $env:ImageVersion + os_version = [Environment]::OSVersion.VersionString + processor = $env:PROCESSOR_IDENTIFIER + } + baseline_tag = $env:BASELINE_TAG + comparison_tag = $env:COMPARISON_TAG + results = @($results) + } + + $report | ConvertTo-Json -Depth 6 | Set-Content ` + -LiteralPath windows-startup-results.json -Encoding utf8 + + @( + "## Windows CLI startup diagnostics" + "" + "All launches used an absolute executable path, an empty working directory, and ``UseShellExecute = false``." + "Runner: ``$($report.runner.image_os) $($report.runner.image_version)``; OS: ``$($report.runner.os_version)``." + "" + "| Executable | Version | Size (MiB) | First (ms) | Median (ms) | p95 (ms) | Signature | MOTW |" + "| --- | --- | ---: | ---: | ---: | ---: | --- | --- |" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + foreach ($result in $results) { + $size = [Math]::Round($result.bytes / 1MB, 2) + "| $($result.name) | $($result.version) | $size | $($result.first_ms) | $($result.median_ms) | $($result.p95_ms) | $($result.signature) | $($result.mark_of_the_web) |" | + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + } + + $results | Format-Table name, version, bytes, first_ms, median_ms, p95_ms + + - name: Upload startup results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-startup-results + path: windows-startup-results.json + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b21f15d..c67c5d64d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- IPC v1 now defines and enforces directional callback limits during + initialization, ties callbacks to their parent request's deadline and + lifetime in both Rust and C clients, and consistently ignores unknown but + structurally valid notifications. Initialization-state failures have + deterministic errors and connection closure, while the provider conformance + runner can use transport-only third-party endpoint profiles and report + provider-specific cases as not applicable. + +- IPC endpoints now answer side-effect-free `rpc.discover` requests before or + after initialization, returning a self-contained OpenRPC description with + endpoint metadata and embedded JSON Schemas for offline inspection tooling. + +- Windows external-provider discovery now distinguishes directory creation + rights from file mutation rights, allowing endpoints below standard protected + volume roots while continuing to reject replaceable path components. + +- External providers now request URI-specific credentials from SecretSpec at + runtime instead of declaring names in their installation claim or receiving + every configured credential during initialization. Provider aliases no + longer need a `credentials` table: `config provider login` discovers requested + names and stores them in a provider-private system-keyring namespace, while + configured sources remain lazy, explicit overrides. + +- Provider failures now preserve opaque interaction references in SecretSpec's + local audit log, allowing CLI and GUI approval surfaces to correlate an + actionable request without treating its ID as authorization material. + - Bitwarden convention item names now preserve project and profile isolation when either contains `/`, and `secretspec init --from bw://` recognizes existing convention names case-insensitively while retaining legacy items' @@ -18,6 +45,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 includes valid when repositories move, distinguishes percent-encoded reserved path bytes, avoids persisting an ambient profile, and exits quietly when its output pipe closes on Unix. +- `libsecretspec-resolver` now links yyjson from the system instead of building + a vendored copy. Building it from source needs yyjson installed, discovered + through pkg-config for Meson or through `find_package(yyjson CONFIG)` for + CMake, and consumers of the static archive must add `-lyyjson` to their link + line. Its `secretspec-resolver.pc` records this as `Requires.private`. The + shared library still exports only its own `secretspec_resolver_*` symbols. +- IPC sessions now use bounded newline-delimited JSON, monotonic request IDs, + and `_meta` request metadata. They distinguish methods from capabilities, + accept forward-compatible result fields, correlate callbacks with their + parent requests, tolerate notification races, and drain accepted work during + graceful shutdown. +- External provider sessions now carry one structured declared project context + for consistent approval and audit displays, including native addresses. + `interaction_required` errors can include an opaque authorization reference + so a provider-owned CLI or notification can identify the pending decision + without exposing remediation text or treating project metadata as identity. +- IPC subprocess sessions now reap children after startup timeouts, preserve a + buffered shutdown response when an endpoint exits immediately, and remain + usable after the terminal response from an expired callback arrives. Callback + requests now honor negotiated concurrency, reject reused IDs for the whole + session, and expire unanswered prompts at their deadline; watchdog-killed + blocking sessions report themselves closed. Resolver-mode ephemeral + generation also stays silent on stderr. +- `secretspec serve --read-only` now refuses any resolution that would write to + a provider, instead of only withholding the store and remove methods. + Resolving is not always a read: a `generate = true` declaration with no stored + value is minted and written back, and a `prompt = true` one is written back + after a person answers, so a read could still reach the store through a + session that advertised no way to write. Both are now refused with + `permission_denied`. Producing a value the provider does not store is + unaffected, and so is SecretSpec's own cache. +- IPC clients now decode an error kind or a resolved-value `source` they do not + recognize instead of failing the session. Both are closed for senders and open + for receivers, so a later protocol revision can name a new failure or a new + value origin without breaking a deployed peer. An unrecognized error is + reported as a failure and never as a success, and a code the client does know + must still arrive with the kind that belongs to it. +- An IPC endpoint that writes a banner, warning, or stack trace to the stream + reserved for protocol frames is now reported as having written non-protocol + text, instead of as an oversized frame. Both the Rust and C clients report it, + and neither echoes the bytes. This is the most common integration failure when + bringing up a new endpoint, and the old message pointed at a frame-size + problem that did not exist. + - Dotenv parsing and rendering now use dotenv-ng throughout the dotenv provider, age-encrypted dotenv blobs, and `secretspec export --format dotenv`. Values containing `$` remain literal, output uses only the quoting @@ -45,6 +116,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Rust SDK and resolver IPC callers can request a default authorization + lifetime for provider approval surfaces. The request is forwarded as + untrusted application context; the provider and approving user retain control + of the actual grant lifetime. + - Static musl CLI release binaries for x64 and arm64 Linux are available in 0.20+, so the standalone installer and `secretspec-update` work on Alpine without a glibc compatibility layer. @@ -96,6 +172,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 provider and reason options are persisted, while `--file` retains the custom-manifest workflow (0.20+). +- `secretspec-ipc` gained a `blocking` feature with a synchronous + `secretspec.resolver/1` session, so a program with no async runtime can talk to + `secretspec serve` without acquiring one. It speaks the same wire + protocol as the async client and passes the same fake-peer conformance cases, + and it pulls in no dependencies beyond the crate's existing serde, serde_json, + thiserror, and zeroize. Deadlines are enforced by terminating the child, since + a blocking pipe read cannot be interrupted. +- `libsecretspec-resolver` can answer prompts, so a C consumer is no longer headless + for a `prompt = true` declaration. It takes no callback: the ABI hands no + function pointer to a foreign runtime, so a session opened with + `SECRETSPEC_RESOLVER_ANSWER_PROMPTS` reports `SECRETSPEC_RESOLVER_PROMPT_PENDING` from a + waiting call, and the caller takes the prompt, answers or declines it, and + waits again. The library adds the advertised capability itself, so a consumer + cannot claim one this build could not answer. +- A declaration with `prompt = true` can now be resolved over IPC. The resolver + has no terminal of its own, so it asks the process that launched it, using the + new `client.prompt` callback, and that process reads the value from a person + and answers on the same session. A client says whether it can answer during + initialization; one that cannot is never asked, so a headless consumer gets + its answer immediately instead of waiting out a deadline. The answer is + persisted exactly as a terminal prompt would persist it. +- `secretspec serve` no longer writes its generation and prompt confirmations to + stderr. Those lines name which secrets a session provisioned, and a resolver's + stderr belongs to whatever launched it. +- Provider reads can now report when the secret itself expires. Resolver + results keep that bound in `expires_at_unix_ms` and expose SecretSpec cache + freshness separately as `refresh_at_unix_ms`, preserving the earliest known + bounds through cached and composed results. +- The Secret Resolution Protocol gained the optional `resolver.set` and + `resolver.delete` methods, so a consumer such as `cargo login` can store or + remove one declared secret where the same session resolves it, rather than + shelling out to `secretspec set` with a manifest of its own. The value lands + on the route the session reads from, an active scope bounds a write exactly as + it bounds a read, and removal stays idempotent. Both are advertised as + capabilities, so a client can tell an endpoint that is older or read-only + apart from one that refused a particular write, and `secretspec serve + --read-only` advertises resolution only. - **Azure App Configuration provider** (`aac://`, 0.20+): select direct values and Azure Key Vault references by label, prefix, and tags, with Entra ID or connection-string authentication and guarded writes, deletion, and @@ -133,6 +246,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `flyctl secrets set` over stdin, refuse boundary whitespace that `flyctl` would silently trim, and scrub ambient Fly token variables before injecting the token selected through the provider credential mechanism. +- SecretSpec 0.20+ adds versioned local IPC: a private stdio resolver, + trusted out-of-tree provider endpoints, independent Rust and pure-C clients, + exact-name resolution with resolver-owned file leases, and shared + schema/OpenRPC/conformance contracts, including executable common-case + drivers for both clients, the Rust provider endpoint and external adapter, + plus the real resolver process. Provider IPC preserves structured error kinds, + never uses protocol streams for prompts, and isolates endpoint state by URI + and reason; discovery precedence and non-replay are covered by executable + tests. IPC deadlines live once on the request envelope, endpoints advertise + their supported application methods, Rust exposes owned typed sessions and + endpoint helpers, and the C client includes a synchronous call convenience + API alongside cancellable call handles. The embedded C ABI is now named `libsecretspec`, + with `libsecretspec.so`/`.dylib`/`.dll`, `libsecretspec.a`, and + `libsecretspec.pc` as its public artifacts; runtime SDK loaders continue to + recognize the pre-0.20 `secretspec-ffi` filenames. - `secretspec completions ` generates completion scripts for Bash, Elvish, Fish, Nushell, PowerShell, and Zsh directly from the CLI definition, including descriptions and contextual suggestions for profiles, scopes, @@ -267,6 +395,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 absolute ref that overrides different configured path defaults, preventing aliased destinations from overwriting one another. +### Fixed + +- SecretSpec 0.20+ IPC enforces Windows ACL isolation for provider discovery + and resolver lease files, bounds cancellation and child-process cleanup by + request deadlines, and validates the same protocol constraints in its Rust + and C clients. Request deadlines are clamped to 300 seconds in the future by + both clients, so a peer cannot hold an in-flight slot indefinitely; a + provider endpoint that ignores shutdown is now always reaped rather than + left behind; and a transport failure still cancels in-flight work and runs + session cleanup. Correcting a rejected base directory or credential set + recovers an external provider instead of disabling it permanently, and a + rejected credential set no longer replaces the accepted one. +- Both IPC clients now report a deadline that has already passed as + `deadline_exceeded` rather than the C client calling it an invalid argument, + so the same mistake has the same kind in either implementation and there is + no cliff at the current instant. Nothing is written and the session stays + usable, exactly as when a deadline elapses in flight. +- SecretSpec 0.20+ IPC now preserves terminal responses that race a callback + deadline or child-process exit, and always gives a killed startup process a + fresh reaping budget. Windows provider discovery validates every executable + ancestor, the C launcher emits the sorted environment block required by + Windows, and prompt answers reject invalid UTF-8 before consuming the prompt. +- On Unix, external provider discovery trusts a path only when every directory + above the endpoint is trusted, not just its immediate parent, since one + writable ancestor lets an attacker swap a component for a symlink to any + executable that satisfies the checks below it. The walk runs over the + resolved path, so a symlinked component is validated as the chain it actually + points at rather than as whatever the registration spelled; a world-writable + ancestor is still accepted when it is sticky, which is what keeps a build or + temporary directory usable. Reported by @djbclark. + ## [0.19.1] - 2026-08-11 Republishes 0.19.0's command-line artifacts. The library and CLI behave exactly diff --git a/Cargo.lock b/Cargo.lock index eb342616d..26d8a6225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,14 +1198,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 1.3.0", + "shlex 2.0.1", ] [[package]] @@ -2159,9 +2159,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -3100,12 +3100,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3113,9 +3114,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3126,9 +3127,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3140,16 +3141,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3160,15 +3162,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3655,9 +3657,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -3684,6 +3686,15 @@ dependencies = [ "libc", ] +[[package]] +name = "libsecretspec" +version = "0.19.1" +dependencies = [ + "secretspec", + "serde_json", + "tempfile", +] + [[package]] name = "linkme" version = "0.3.35" @@ -5310,6 +5321,7 @@ name = "secretspec" version = "0.19.1" dependencies = [ "age", + "async-trait", "aws-config", "aws-sdk-secretsmanager", "aws-sdk-ssm", @@ -5345,6 +5357,7 @@ dependencies = [ "rust-ini", "same-file", "secrecy", + "secretspec-ipc", "serde", "serde_json", "sha2 0.10.9", @@ -5357,6 +5370,7 @@ dependencies = [ "url", "uuid", "whoami", + "windows-sys 0.61.2", ] [[package]] @@ -5376,12 +5390,36 @@ dependencies = [ ] [[package]] -name = "secretspec-ffi" +name = "secretspec-ipc" version = "0.19.1" dependencies = [ + "async-trait", + "jsonschema", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "zeroize", +] + +[[package]] +name = "secretspec-ipc-conformance" +version = "0.19.1" +dependencies = [ + "async-trait", + "cc", + "pkg-config", + "proptest", + "secrecy", "secretspec", + "secretspec-ipc", + "serde", "serde_json", "tempfile", + "tokio", ] [[package]] @@ -6026,9 +6064,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -6117,6 +6155,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -7320,9 +7359,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wsl" @@ -7350,9 +7389,9 @@ checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7361,9 +7400,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -7506,9 +7545,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -7517,9 +7556,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "serde", "yoke", @@ -7529,13 +7568,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3d3fb2b33..368313fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,9 @@ members = [ "secretspec", "secretspec-derive", - "secretspec-ffi", + "libsecretspec", + "secretspec-ipc", + "conformance/ipc/runner", "secretspec-node", "secretspec-php", "secretspec-py", @@ -62,6 +64,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus tokio = { version = "1", features = ["rt", "rt-multi-thread"] } secretspec-derive = { version = "0.19.1", path = "./secretspec-derive" } secretspec = { version = "0.19.1", path = "./secretspec" } +secretspec-core = { package = "secretspec", version = "0.19.1", path = "./secretspec" } rand = "0.9" rsa = { version = "0.9", features = ["pem"] } uuid = { version = "1", features = ["serde", "v4"] } @@ -69,6 +72,10 @@ data-encoding = "2" sha2 = "0.10" detect-coding-agent = "0.1" age = { version = "0.12", features = ["armor", "plugin", "ssh"] } +async-trait = "0.1" +tokio-util = { version = "0.7", features = ["rt"] } +zeroize = { version = "1.8", features = ["derive"] } +secretspec-ipc = { version = "0.19.1", path = "./secretspec-ipc" } # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index b0d9d5661..f51c0a086 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,6 @@ # Releasing the language SDKs -Each SDK is a thin client over the Rust core (the `secretspec-ffi` C ABI, a +Each SDK is a thin client over the Rust core (the `libsecretspec` C ABI, a pyo3 extension for Python, or the napi-rs addon for Node). A release builds the native artifact per platform and publishes it through that ecosystem's registry or as a checksummed SwiftPM binary, so users install with no native @@ -10,6 +10,28 @@ Version tags are `vX.Y.Z`; the publish jobs trigger on them. After the Go release build succeeds, CI also creates the submodule tag `secretspec-go/vX.Y.Z`, which is the version the Go module proxy resolves. +## Native library rename gate for 0.20 + +SecretSpec 0.20 renames the embedded native library from `secretspec-ffi` / +`secretspec_ffi` to `libsecretspec` without renaming its three exported C +symbols. Before tagging 0.20: + +1. Run the SDK workflow and every platform packaging workflow from the release + commit, including the Windows native assets. +2. Verify Go purego, .NET, and PHP FFI discover the pre-0.20 shared-library + filenames, and Ruby source builds discover `libsecretspec_ffi.a`. +3. Verify newly packaged artifacts contain only the new public filename and + that `SECRETSPEC_FFI_LIB` can explicitly select either compatible library. +4. Test one clean install per SDK without a source checkout or Cargo `target/` + directory present, so development fallback discovery cannot hide a missing + packaged asset. +5. Keep `libsecretspec` and each SDK at the same release version. Filename + fallback preserves loading, not newer request fields or behavior in an old + library. + +The detailed compatibility matrix is maintained in +[`libsecretspec/README.md`](libsecretspec/README.md). + ## After every release Once the release artifacts are available, update the `secretspec` package in @@ -200,7 +222,7 @@ published release tag for recovery or retry. Prerelease tags are skipped. ## Ruby (RubyGems) — `ruby-gems.yml` - **Build:** a platform gem (`Gem::Platform::CURRENT`) bundling the - `secretspec-ffi` staticlib in `vendor/`. At `gem install`, mkmf compiles a tiny + `libsecretspec` staticlib in `vendor/`. At `gem install`, mkmf compiles a tiny C glue and statically links that archive, so the resolver is embedded in the extension and one platform gem serves every Ruby ABI (install needs a C compiler and Ruby headers). @@ -224,7 +246,7 @@ history permanently and ships every platform's lib in the module zip.) So the Go SDK follows the purego norm: the cdylib is provided at runtime, not shipped through the module. Consumers either set `SECRETSPEC_FFI_LIB` to an -installed/built `libsecretspec_ffi`, or build with `-tags embed_lib` after +installed/built `libsecretspec`, or build with `-tags embed_lib` after staging the per-platform library into `secretspec-go/lib/` themselves (a self-contained, vendored build — not a module-proxy install). @@ -241,7 +263,7 @@ self-contained, vendored build — not a module-proxy install). ## Haskell (Hackage) — `haskell-build.yml` -- **Build:** statically links the `secretspec-ffi` archive at build time via +- **Build:** statically links the `libsecretspec` archive at build time via the GHC FFI, so the Rust resolver is embedded in the binary with no runtime loader path. - **Publish:** `cabal upload --publish` with the `HACKAGE_TOKEN` secret — see @@ -256,7 +278,7 @@ self-contained, vendored build — not a module-proxy install). ## Swift (0.18+, SwiftPM + XCFramework) — `swift-package.yml` - **Build:** native Intel and Apple-silicon macOS runners build the - `secretspec-ffi` cdylib with a macOS 12 deployment target. + `libsecretspec` cdylib with a macOS 12 deployment target. `scripts/build-swift-xcframework.sh` gives each dylib an `@rpath` install name, combines the slices into a universal dylib, adds the public header and Clang module map, and wraps it with `xcodebuild -create-xcframework`. @@ -308,7 +330,7 @@ through Composer. attached to the release. Users install it by dropping the `.so` in and `extension=` / `docker-php-ext-enable`, or by building from source with cargo. - **ext-ffi fallback library.** For the no-extension path, `ffi-build.yml` - attaches the per-target `secretspec-ffi` library (with a `.sha256`) to the + attaches the per-target `libsecretspec` library (with a `.sha256`) to the release; the client's `vendor/bin/secretspec-install-lib` command downloads the right one on demand. It is a deliberate opt-in command, not a Composer post-install hook (a dependency's install scripts do not run in the consumer @@ -354,7 +376,7 @@ In order: managed package dependencies. It invokes the stable JSON C ABI through source-generated P/Invoke and exposes the same builder, resolved value, report, and typed-error vocabulary as the other SDKs. -- **Native assets:** one NuGet package carries `secretspec-ffi` under the +- **Native assets:** one NuGet package carries `libsecretspec` under the standard `runtimes//native/` layout for glibc and musl Linux x64/Arm64, macOS x64/Arm64, and Windows x64/Arm64. Glibc builds use a manylinux 2.28 baseline, and Windows builds statically link the MSVC runtime. diff --git a/conformance/README.md b/conformance/README.md index c409859c2..9cb0f55d6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -41,7 +41,7 @@ the comparison is deterministic and meaningful across languages. ## Running Run everything with the aggregate runner (inside the project devenv shell). It -builds the `secretspec-ffi` library once, points the runtime-loading SDKs at the +builds the `libsecretspec` library once, points the runtime-loading SDKs at the cdylib via `SECRETSPEC_FFI_LIB` and stages the staticlib for the SDKs that link it (Haskell), runs each language's conformance suite, and prints a combined PASS/FAIL/SKIP summary (exiting non-zero if any language fails): @@ -57,7 +57,7 @@ relative to the repo root: - Go: `cd secretspec-go && go test ./...` - Ruby: `cd secretspec-rb && ruby test/test_resolve.rb` - Node: `cd secretspec-node && node --test` -- Haskell: `cd secretspec-hs && cabal test` (needs the `secretspec-ffi` +- Haskell: `cd secretspec-hs && cabal test` (needs the `libsecretspec` staticlib staged on `--extra-lib-dirs`; see the Haskell SDK build steps) - C#: `cd secretspec-dotnet && dotnet run --project tests/SecretSpec.Tests` - PHP: `cd secretspec-php && ./vendor/bin/phpunit tests/ConformanceTest.php` diff --git a/conformance/ipc/README.md b/conformance/ipc/README.md new file mode 100644 index 000000000..74dc5b9fe --- /dev/null +++ b/conformance/ipc/README.md @@ -0,0 +1,114 @@ +# SecretSpec IPC conformance suite + +This directory contains the language-neutral cases for +`secretspec.resolver/1`, `secretspec.provider/1`, and the shared wire protocol. +It is available with SecretSpec 0.20+. + +Validate the checked-in case documents and the presence and JSON shape of the +schema/OpenRPC assets with: + +```console +cargo run -p secretspec-ipc-conformance -- check +``` + +Validate every fixture against its JSON Schema, resolve all schema references, +and compare the OpenRPC method catalogs with the Rust constants with: + +```console +cargo test -p secretspec-ipc --test fixtures +``` + +A target driver receives one case document on standard input and writes one +normalized JSON transcript on standard output: + +```json +{"case":"wire.fragmented-frame","events":[{"kind":"accepted"}]} +``` + +Run a driver command directly (without a shell) with: + +```console +cargo run -p secretspec-ipc-conformance -- run wire ./path/to/driver --stdio +``` + +The runner selects `common` plus the named target's cases, enforces each case's +timeout, rejects stderr or transcripts containing the canary, and checks the +required event kinds. A driver must implement public byte/process behavior; it +must not call private implementation internals. The same command protocol is +used by C and Rust client drivers so their normalized transcripts can be +compared by CI and by third-party provider endpoints. The checked-in +`ipc-client-conformance-driver` has independent `c` and `rust` modes and runs +the common wire cases plus `client.lifecycle` through each public client. + +Rust server coverage includes `rpc.discover` (0.20+): it works before and after +initialization, returns a self-contained OpenRPC document, preserves increasing +request IDs into initialization, and never initializes application state or +issues a callback merely to describe the endpoint. + +Run the executable client matrix directly with: + +```console +cargo test -p secretspec-ipc-conformance --test client_cases +``` + +That test invokes the conformance runner as an external process twice. It does +not call runner internals or substitute the differential model for either +client. + +The provider matrix launches a deterministic, stateful +`secretspec.provider/1` endpoint and runs the checked-in wire, operation, +expiry, clear, cancellation, deadline, crash, and reconnect cases through both +the Rust endpoint API and SecretSpec's external-provider adapter. It also +checks structured error preservation, one-shot failure non-replay, and process +isolation across provider URIs and reasons: + +```console +cargo test -p secretspec-ipc-conformance --test provider_cases +``` + +Third-party endpoints can run the same target with a transport-only endpoint +profile (0.20+). The executable still comes from `--endpoint`; the profile +supplies its arguments, replacement environment, provider scheme/URI, expected +identity, and advertised method set: + +```console +cargo run -p secretspec-ipc-conformance -- run provider-endpoint \ + ipc-provider-conformance-driver \ + --implementation endpoint \ + --endpoint /path/to/provider-endpoint \ + --profile conformance/ipc/profiles/transport-only.example.json +``` + +This profile runs framing, strict-wire, initialization, notification, and +connection-lifecycle cases without assuming provider storage semantics. Cases +that require the deterministic memory provider's disposable state, magic error +fixtures, or crash hooks are reported as `not applicable` rather than failed. +Because a transport-only profile has no operation that can safely trigger an +arbitrary provider's callbacks, callback brokerage is also outside this layer. +The bundled memory endpoint remains the full provider-operation matrix, and +callback paths require their own declared fixture. Profile `environment` +entries replace, rather than extend, the driver's environment so endpoint tests +do not accidentally depend on ambient secrets. + +The resolver cases are consumed by integration tests that launch the real +`secretspec serve` executable. They verify inline initialization, exact-name +value/missing/undeclared results, file mode, duplicate release, disconnect +cleanup, cached-value rejection, and interactive versus headless prompt +handling: + +```console +cargo test -p secretspec --test ipc_resolver +``` + +`cases/` is canonical test data rather than executable expectations hidden in +one language. The property tests additionally: + +- generate frame histories and compare the production incremental decoder with + an independent reference decoder; and +- serialize state-aware echo, cancellation, and deadline histories, run each + history against the actual pure-C ABI and independent Rust client through the + same deterministic child peer, and compare normalized outcomes. + +Run those checks with `cargo test -p secretspec-ipc-conformance`. Proptest +prints the replay seed and the assertion includes the serialized history when a +difference is found. Minimized regressions belong in `cases/`. diff --git a/conformance/ipc/cases/client-lifecycle.json b/conformance/ipc/cases/client-lifecycle.json new file mode 100644 index 000000000..1ee468484 --- /dev/null +++ b/conformance/ipc/cases/client-lifecycle.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "id": "client.lifecycle", + "targets": ["c-client", "rust-client"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "protocol": "secretspec.resolver", "version": 1}, + {"kind": "call", "method": "resolver.get", "deadline_after_ms": 1000}, + {"kind": "cancel"}, + {"kind": "close", "deadline_after_ms": 1000} + ], + "required_events": ["initialized", "terminal", "child_reaped", "closed"] +} diff --git a/conformance/ipc/cases/provider-errors.json b/conformance/ipc/cases/provider-errors.json new file mode 100644 index 000000000..7c25dde9d --- /dev/null +++ b/conformance/ipc/cases/provider-errors.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "id": "provider.errors", + "targets": ["provider-endpoint", "external-adapter"], + "timeout_ms": 10000, + "actions": [ + {"kind": "call", "method": "provider.get", "behavior": "interaction_required"}, + {"kind": "call", "method": "provider.get", "behavior": "permission_denied"}, + {"kind": "call", "method": "provider.set", "behavior": "conflict"}, + {"kind": "call", "method": "provider.get", "behavior": "unavailable"}, + {"kind": "explicit_retry", "automatic": false}, + {"kind": "shutdown"} + ], + "required_events": [ + "initialized", + "interaction_required", + "permission_denied", + "conflict", + "unavailable", + "not_replayed", + "closed" + ] +} diff --git a/conformance/ipc/cases/provider-lifecycle.json b/conformance/ipc/cases/provider-lifecycle.json new file mode 100644 index 000000000..6831b3edc --- /dev/null +++ b/conformance/ipc/cases/provider-lifecycle.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "id": "provider.lifecycle", + "targets": ["provider-endpoint"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "protocol": "secretspec.provider", "version": 1}, + {"kind": "call", "method": "provider.get", "behavior": "pending"}, + {"kind": "cancel"}, + {"kind": "deadline", "after_ms": 50}, + {"kind": "shutdown"} + ], + "required_events": [ + "initialized", + "cancelled", + "deadline_exceeded", + "terminal", + "closed" + ] +} diff --git a/conformance/ipc/cases/provider-operations.json b/conformance/ipc/cases/provider-operations.json new file mode 100644 index 000000000..3ab88ea30 --- /dev/null +++ b/conformance/ipc/cases/provider-operations.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "id": "provider.operations", + "targets": ["rust-server", "provider-endpoint", "external-adapter"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "protocol": "secretspec.provider", "version": 1, "scheme": "memory"}, + {"kind": "call", "method": "provider.resolve_address"}, + {"kind": "call", "method": "provider.get"}, + {"kind": "call", "method": "provider.get_many"}, + {"kind": "call", "method": "provider.exists"}, + {"kind": "call", "method": "provider.set"}, + {"kind": "call", "method": "provider.set_expiring"}, + {"kind": "call", "method": "provider.delete"}, + {"kind": "call", "method": "provider.clear"}, + {"kind": "call", "method": "provider.check_writable"}, + {"kind": "call", "method": "provider.check_deletable"}, + {"kind": "call", "method": "provider.describe_write_target"}, + {"kind": "call", "method": "provider.reflect"}, + {"kind": "shutdown"} + ], + "required_events": [ + "initialized", + "resolved_address", + "read", + "secret_expiry_reported", + "batched", + "preflighted", + "mutated", + "expired", + "cleared", + "reflected", + "closed" + ] +} diff --git a/conformance/ipc/cases/provider-reconnect.json b/conformance/ipc/cases/provider-reconnect.json new file mode 100644 index 000000000..3eaf05d64 --- /dev/null +++ b/conformance/ipc/cases/provider-reconnect.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "id": "provider.reconnect", + "targets": ["external-adapter"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "protocol": "secretspec.provider", "version": 1}, + {"kind": "crash", "during": "provider.get"}, + {"kind": "reconnect", "replay_failed_request": false}, + {"kind": "shutdown"} + ], + "required_events": ["initialized", "crashed", "reconnected", "closed"] +} diff --git a/conformance/ipc/cases/provider-session-isolation.json b/conformance/ipc/cases/provider-session-isolation.json new file mode 100644 index 000000000..3f87acfd5 --- /dev/null +++ b/conformance/ipc/cases/provider-session-isolation.json @@ -0,0 +1,24 @@ +{ + "schema_version": 1, + "id": "provider.session-isolation", + "targets": ["external-adapter"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "uri": "memory://session-a", "reason": "session-a"}, + {"kind": "call", "method": "provider.set", "address": "shared-address"}, + {"kind": "shutdown"}, + {"kind": "initialize", "uri": "memory://session-b", "reason": "session-b"}, + {"kind": "call", "method": "provider.get", "address": "shared-address", "expect": "missing"}, + {"kind": "change_reason", "reason": "session-b-reason-2"}, + {"kind": "call", "method": "provider.get", "address": "shared-address", "expect": "missing"}, + {"kind": "shutdown"} + ], + "required_events": [ + "session_a_initialized", + "session_a_closed", + "session_b_initialized", + "uri_isolated", + "reason_isolated", + "closed" + ] +} diff --git a/conformance/ipc/cases/resolver-leases.json b/conformance/ipc/cases/resolver-leases.json new file mode 100644 index 000000000..3658f4bda --- /dev/null +++ b/conformance/ipc/cases/resolver-leases.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "id": "resolver.path-leases", + "targets": ["resolver", "rust-server"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "manifest": "inline", "profile": "default"}, + {"kind": "resolve", "name": "TOKEN", "representation": "value"}, + {"kind": "resolve", "name": "OPTIONAL", "representation": "value"}, + {"kind": "resolve", "name": "UNKNOWN", "representation": "auto"}, + {"kind": "resolve", "name": "CERT", "representation": "path"}, + {"kind": "release", "duplicates": true}, + {"kind": "resolve", "name": "CERT", "representation": "path"}, + {"kind": "disconnect"} + ], + "required_events": [ + "initialized", + "resolved_value", + "missing", + "undeclared", + "lease_created", + "lease_removed", + "disconnect_cleanup", + "closed" + ] +} diff --git a/conformance/ipc/cases/resolver-prompt.json b/conformance/ipc/cases/resolver-prompt.json new file mode 100644 index 000000000..fe7d2d67a --- /dev/null +++ b/conformance/ipc/cases/resolver-prompt.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "id": "resolver.prompt", + "targets": ["resolver"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize", "manifest": "inline", "profile": "default", "client_methods": ["client.prompt"]}, + {"kind": "resolve", "name": "DEPLOY_PASSWORD", "representation": "value", "expect": "prompted"}, + {"kind": "disconnect"}, + {"kind": "initialize", "manifest": "inline", "profile": "default", "client_methods": []}, + {"kind": "resolve", "name": "DEPLOY_PASSWORD", "representation": "value", "expect": "missing"}, + {"kind": "disconnect"} + ], + "required_events": [ + "initialized", + "prompt_requested", + "prompt_answered", + "answer_persisted", + "headless_missing", + "no_prompt_requested", + "closed" + ] +} diff --git a/conformance/ipc/cases/wire-fragmented.json b/conformance/ipc/cases/wire-fragmented.json new file mode 100644 index 000000000..628c99387 --- /dev/null +++ b/conformance/ipc/cases/wire-fragmented.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "id": "wire.fragmented-frame", + "targets": ["common"], + "timeout_ms": 5000, + "actions": [ + {"kind": "launch", "protocol": "secretspec.resolver"}, + {"kind": "peer_write", "chunks": [1, 1, 1, 1, 2, 3, 5]}, + {"kind": "shutdown"} + ], + "required_events": ["initialized", "frame_accepted", "closed"] +} diff --git a/conformance/ipc/cases/wire-initialization-state.json b/conformance/ipc/cases/wire-initialization-state.json new file mode 100644 index 000000000..699f3c801 --- /dev/null +++ b/conformance/ipc/cases/wire-initialization-state.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "id": "wire.initialization-state", + "targets": ["provider-endpoint", "rust-server"], + "timeout_ms": 10000, + "actions": [ + {"kind": "application_before_initialize"}, + {"kind": "response_before_initialize"}, + {"kind": "second_initialize"}, + {"kind": "unsupported_version"}, + {"kind": "invalid_params"} + ], + "required_events": [ + "application_before_initialize_rejected", + "response_before_initialize_closed", + "second_initialize_rejected", + "unsupported_version_rejected", + "invalid_params_rejected", + "closed" + ] +} diff --git a/conformance/ipc/cases/wire-lifecycle.json b/conformance/ipc/cases/wire-lifecycle.json new file mode 100644 index 000000000..2b2bff242 --- /dev/null +++ b/conformance/ipc/cases/wire-lifecycle.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "id": "wire.lifecycle", + "targets": ["provider-endpoint", "rust-server"], + "timeout_ms": 10000, + "actions": [ + {"kind": "initialize"}, + {"kind": "unknown_method"}, + {"kind": "shutdown"}, + {"kind": "disconnect"}, + {"kind": "reconnect"} + ], + "required_events": [ + "initialized", + "capability_gated", + "shutdown", + "disconnect_cleaned_up", + "reconnected", + "closed" + ] +} diff --git a/conformance/ipc/cases/wire-notifications.json b/conformance/ipc/cases/wire-notifications.json new file mode 100644 index 000000000..7220c7c05 --- /dev/null +++ b/conformance/ipc/cases/wire-notifications.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "id": "wire.notifications", + "targets": ["provider-endpoint", "rust-server"], + "timeout_ms": 10000, + "actions": [ + {"kind": "unknown_method"}, + {"kind": "malformed_cancel"}, + {"kind": "unknown_cancel_id"}, + {"kind": "terminal_cancel_id"}, + {"kind": "unknown_member"} + ], + "required_events": [ + "unknown_notification_ignored", + "malformed_cancel_ignored", + "unknown_cancel_ignored", + "terminal_cancel_ignored", + "unknown_member_rejected", + "closed" + ] +} diff --git a/conformance/ipc/cases/wire-rejections.json b/conformance/ipc/cases/wire-rejections.json new file mode 100644 index 000000000..cbd553c46 --- /dev/null +++ b/conformance/ipc/cases/wire-rejections.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "id": "wire.strict-rejections", + "targets": ["common"], + "timeout_ms": 5000, + "actions": [ + {"kind": "raw_frame", "payload_hex": ""}, + {"kind": "raw_frame", "payload_hex": "ff"}, + {"kind": "raw_frame", "prefix_hex": "0000"}, + {"kind": "raw_frame", "declared_length": 10, "payload_hex": "7b7d"}, + {"kind": "raw_frame", "payload_utf8": "[]"}, + {"kind": "raw_frame", "payload_utf8": "{\"jsonrpc\":\"2.0\",\"jsonrpc\":\"2.0\"}"}, + {"kind": "raw_frame", "payload_utf8": "{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{}}"} + ], + "required_events": ["rejected", "closed"] +} diff --git a/conformance/ipc/profiles/transport-only.example.json b/conformance/ipc/profiles/transport-only.example.json new file mode 100644 index 000000000..3522d87c1 --- /dev/null +++ b/conformance/ipc/profiles/transport-only.example.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "kind": "transport_only", + "scheme": "fixture", + "uri": "fixture://conformance", + "provider_name": "fixture", + "expected_methods": [ + "provider.resolve_address", + "provider.get", + "provider.get_many", + "provider.exists", + "provider.set", + "provider.set_expiring", + "provider.delete", + "provider.clear", + "provider.check_writable", + "provider.check_deletable", + "provider.describe_write_target", + "provider.reflect" + ], + "arguments": [], + "environment": {} +} diff --git a/conformance/ipc/runner/Cargo.toml b/conformance/ipc/runner/Cargo.toml new file mode 100644 index 000000000..7f5bf2cac --- /dev/null +++ b/conformance/ipc/runner/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "secretspec-ipc-conformance" +version.workspace = true +edition.workspace = true +publish = false +default-run = "secretspec-ipc-conformance" + +[dependencies] +async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +secrecy.workspace = true +secretspec = { path = "../../../secretspec", default-features = false } +secretspec-ipc = { workspace = true, features = ["blocking"] } +tempfile.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"] } + +[dev-dependencies] +proptest.workspace = true + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/conformance/ipc/runner/build.rs b/conformance/ipc/runner/build.rs new file mode 100644 index 000000000..6f7bf12e8 --- /dev/null +++ b/conformance/ipc/runner/build.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +/// Point the C sources at the system yyjson. +/// +/// pkg-config is the portable answer: Nix, Homebrew, apt, and vcpkg all ship +/// `yyjson.pc`. Runners without pkg-config (Windows) can name the install +/// prefix through `YYJSON_INCLUDE_DIR` and `YYJSON_LIB_DIR` instead. Inside the +/// devenv shell neither path is needed, because `pkgs.yyjson` already puts its +/// header and library on the compiler's search paths through +/// `NIX_CFLAGS_COMPILE` and `NIX_LDFLAGS`. +fn link_yyjson(build: &mut cc::Build) { + println!("cargo:rerun-if-env-changed=YYJSON_INCLUDE_DIR"); + println!("cargo:rerun-if-env-changed=YYJSON_LIB_DIR"); + + let include_dir = std::env::var_os("YYJSON_INCLUDE_DIR"); + let lib_dir = std::env::var_os("YYJSON_LIB_DIR"); + if include_dir.is_none() && lib_dir.is_none() { + // `probe` emits the link directives itself when it succeeds. + if let Ok(yyjson) = pkg_config::Config::new().probe("yyjson") { + for path in yyjson.include_paths { + build.include(path); + } + return; + } + } + + if let Some(path) = include_dir { + build.include(path); + } + if let Some(path) = lib_dir { + println!( + "cargo:rustc-link-search=native={}", + PathBuf::from(path).display() + ); + } + println!("cargo:rustc-link-lib=yyjson"); +} + +fn main() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../libsecretspec-resolver"); + let mut build = cc::Build::new(); + build + .std("c11") + .opt_level(1) + .warnings(true) + .extra_warnings(true) + .warnings_into_errors(true) + .flag_if_supported("-Wpedantic") + .flag_if_supported("-fvisibility=hidden") + .define("SECRETSPEC_RESOLVER_BUILDING", None) + .include(root.join("include")) + .include(root.join("src")) + .file(root.join("src/frame.c")) + .file(root.join("src/json.c")) + .file(root.join("src/secure_memory.c")) + .file(root.join("src/session.c")); + + link_yyjson(&mut build); + + // MSVC gates behind an opt-in switch even in C11 mode. + if build.get_compiler().is_like_msvc() { + build.flag("/experimental:c11atomics"); + } + + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + build.file(root.join("src/process_windows.c")); + } else { + build.file(root.join("src/process_posix.c")); + } + build.compile("secretspec_resolver_conformance_c"); + + if std::env::var_os("CARGO_CFG_UNIX").is_some() { + println!("cargo:rustc-link-lib=pthread"); + } + println!("cargo:rerun-if-changed={}", root.display()); +} diff --git a/conformance/ipc/runner/src/bin/ipc-client-conformance-driver.rs b/conformance/ipc/runner/src/bin/ipc-client-conformance-driver.rs new file mode 100644 index 000000000..5765ce451 --- /dev/null +++ b/conformance/ipc/runner/src/bin/ipc-client-conformance-driver.rs @@ -0,0 +1,626 @@ +use secretspec_ipc::error::Error; +use secretspec_ipc::lifecycle::{self, Environment, LaunchOptions}; +use secretspec_ipc::protocol::{InitializeParams, Limits, Product}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::ffi::{c_uchar, c_void}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const ABI_VERSION: u32 = 1 << 16; +const STATUS_OK: i32 = 0; +const STATUS_CANCELLED: i32 = 6; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + schema_version: u32, + id: String, + targets: Vec, + timeout_ms: u64, + actions: Vec, + required_events: Vec, +} + +enum Implementation { + C, + Rust, +} + +enum Scenario { + Fragmented { + chunks: Vec, + }, + Rejections { + peer_arguments: Vec>, + }, + Lifecycle { + method: String, + call_timeout: Duration, + close_timeout: Duration, + }, +} + +fn main() { + if let Err(error) = execute() { + eprintln!("client conformance driver: {error}"); + std::process::exit(1); + } +} + +fn execute() -> Result<(), String> { + let (implementation, peer) = arguments()?; + let mut input = Vec::new(); + io::stdin() + .take(1024 * 1024) + .read_to_end(&mut input) + .map_err(|error| error.to_string())?; + let case: Case = serde_json::from_slice(&input).map_err(|error| error.to_string())?; + if case.schema_version != 1 + || case.targets.is_empty() + || case.timeout_ms == 0 + || case.required_events.is_empty() + { + return Err("invalid conformance case envelope".into()); + } + let scenario = scenario(&case)?; + let events = match implementation { + Implementation::C => run_c(&peer, scenario)?, + Implementation::Rust => run_rust(&peer, scenario)?, + }; + serde_json::to_writer( + io::stdout().lock(), + &json!({"case": case.id, "events": events}), + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn arguments() -> Result<(Implementation, PathBuf), String> { + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--implementation")) { + return Err("expected --implementation c|rust --peer PATH".into()); + } + let implementation = match arguments.next().and_then(|value| value.into_string().ok()) { + Some(value) if value == "c" => Implementation::C, + Some(value) if value == "rust" => Implementation::Rust, + _ => return Err("implementation must be c or rust".into()), + }; + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--peer")) { + return Err("expected --peer PATH".into()); + } + let peer = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing peer path".to_string())?; + if arguments.next().is_some() { + return Err("unexpected driver argument".into()); + } + Ok((implementation, peer)) +} + +fn scenario(case: &Case) -> Result { + match case.id.as_str() { + "wire.fragmented-frame" => { + require_action(&case.actions, "launch")?; + require_action(&case.actions, "shutdown")?; + let chunks = require_action(&case.actions, "peer_write")? + .get("chunks") + .and_then(Value::as_array) + .ok_or_else(|| "fragmented case has no chunks".to_string())? + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "invalid fragment size".to_string()) + }) + .collect::, _>>()?; + Ok(Scenario::Fragmented { chunks }) + } + "wire.strict-rejections" => { + let peer_arguments = case + .actions + .iter() + .filter(|action| action.get("kind").and_then(Value::as_str) == Some("raw_frame")) + .map(rejection_arguments) + .collect::, _>>()?; + if peer_arguments.is_empty() { + return Err("strict rejection case has no frames".into()); + } + Ok(Scenario::Rejections { peer_arguments }) + } + "client.lifecycle" => { + let initialize = require_action(&case.actions, "initialize")?; + if initialize.get("protocol").and_then(Value::as_str) != Some("secretspec.resolver") + || initialize.get("version").and_then(Value::as_u64) != Some(1) + { + return Err("client lifecycle selects an unsupported protocol".into()); + } + require_action(&case.actions, "cancel")?; + let call = require_action(&case.actions, "call")?; + let close = require_action(&case.actions, "close")?; + Ok(Scenario::Lifecycle { + method: call + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| "client lifecycle call has no method".to_string())? + .to_string(), + call_timeout: action_timeout(call)?, + close_timeout: action_timeout(close)?, + }) + } + other => Err(format!("unsupported client conformance case {other}")), + } +} + +fn require_action<'a>(actions: &'a [Value], kind: &str) -> Result<&'a Value, String> { + actions + .iter() + .find(|action| action.get("kind").and_then(Value::as_str) == Some(kind)) + .ok_or_else(|| format!("case has no {kind} action")) +} + +fn action_timeout(action: &Value) -> Result { + action + .get("deadline_after_ms") + .and_then(Value::as_u64) + .filter(|value| *value != 0) + .map(Duration::from_millis) + .ok_or_else(|| "action has no positive deadline".to_string()) +} + +fn rejection_arguments(action: &Value) -> Result, String> { + let value = if action.get("prefix_hex").and_then(Value::as_str) == Some("0000") { + "truncated-header".to_string() + } else if action.get("declared_length").and_then(Value::as_u64) == Some(10) + && action.get("payload_hex").and_then(Value::as_str) == Some("7b7d") + { + "truncated-payload".to_string() + } else if action.get("payload_hex").and_then(Value::as_str) == Some("") { + "empty".to_string() + } else if action.get("payload_hex").and_then(Value::as_str) == Some("ff") { + "invalid-utf8".to_string() + } else if action.get("payload_utf8").and_then(Value::as_str) == Some("[]") { + "batch".to_string() + } else if action + .get("payload_utf8") + .and_then(Value::as_str) + .is_some_and(|value| value == r#"{"jsonrpc":"2.0","jsonrpc":"2.0"}"#) + { + "duplicate-key".to_string() + } else if action + .get("payload_utf8") + .and_then(Value::as_str) + .is_some_and(|value| value == r#"{"jsonrpc":"2.0","id":999,"result":{}}"#) + { + "unknown-id".to_string() + } else if let Some(length) = action.get("declared_length").and_then(Value::as_u64) { + format!( + "oversized:{}", + u32::try_from(length).map_err(|_| "declared length exceeds u32".to_string())? + ) + } else { + return Err("unsupported strict rejection action".into()); + }; + Ok(vec!["--reject-init".into(), value]) +} + +fn event(kind: &str) -> Value { + json!({"kind": kind}) +} + +fn initialize_params() -> InitializeParams { + InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "conformance-driver".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + } +} + +fn launch_options(peer: &Path, arguments: &[String]) -> LaunchOptions { + LaunchOptions { + executable: peer.to_path_buf(), + arguments: arguments.iter().map(Into::into).collect(), + environment: Environment::Replace(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 4096, + } +} + +fn run_rust(peer: &Path, scenario: Scenario) -> Result, String> { + tokio::runtime::Runtime::new() + .map_err(|error| error.to_string())? + .block_on(async move { + match scenario { + Scenario::Fragmented { chunks } => { + let arguments = vec![ + "--fragment-init".into(), + chunks + .iter() + .map(usize::to_string) + .collect::>() + .join(","), + ]; + let (session, _) = lifecycle::spawn::<_, Value>( + launch_options(peer, &arguments), + initialize_params(), + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + session + .close(deadline_after(Duration::from_secs(2))) + .await + .map_err(stable)?; + Ok(vec![ + event("initialized"), + event("frame_accepted"), + event("closed"), + ]) + } + Scenario::Rejections { peer_arguments } => { + let mut events = Vec::with_capacity(peer_arguments.len() + 1); + for arguments in peer_arguments { + if lifecycle::spawn::<_, Value>( + launch_options(peer, &arguments), + initialize_params(), + deadline_after(Duration::from_secs(2)), + ) + .await + .is_ok() + { + return Err( + "Rust client accepted an invalid initialization frame".into() + ); + } + events.push(event("rejected")); + } + events.push(event("closed")); + Ok(events) + } + Scenario::Lifecycle { + method, + call_timeout, + close_timeout, + } => { + let (session, _) = lifecycle::spawn::<_, Value>( + launch_options(peer, &[]), + initialize_params(), + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + let deadline = deadline_after(call_timeout); + let mut call = session + .client() + .start(&method, &json!({"mode": "pending"}), deadline) + .await + .map_err(stable)?; + call.cancel().await.map_err(stable)?; + if !matches!(call.wait().await, Err(Error::Cancelled)) { + return Err( + "Rust cancellation did not produce one cancelled terminal".into() + ); + } + session + .close(deadline_after(close_timeout)) + .await + .map_err(stable)?; + Ok(vec![ + event("initialized"), + event("terminal"), + event("child_reaped"), + event("closed"), + ]) + } + } + }) +} + +fn stable(error: secretspec_ipc::Error) -> String { + error.stable_message().to_string() +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Slice { + data: *const c_uchar, + size: usize, +} + +#[repr(C)] +struct Options { + struct_size: u32, + abi_version: u32, + flags: u32, + reserved: u32, + executable: Slice, + arguments: *const Slice, + argument_count: usize, + environment: *const Slice, + environment_count: usize, + initialize_params_json: Slice, + max_stderr_bytes: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Buffer { + data: *mut c_uchar, + size: usize, +} + +unsafe extern "C" { + fn secretspec_resolver_abi_version() -> u32; + fn secretspec_resolver_client_open( + options: *const Options, + deadline_unix_ms: u64, + client: *mut *mut c_void, + server_info: *mut Buffer, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_start( + client: *mut c_void, + method: *const c_uchar, + method_size: usize, + params_json: *const c_uchar, + params_size: usize, + deadline_unix_ms: u64, + call: *mut *mut c_void, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_wait( + call: *mut c_void, + result: *mut Buffer, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_cancel(call: *mut c_void); + fn secretspec_resolver_call_free(call: *mut c_void); + fn secretspec_resolver_client_close( + client: *mut c_void, + deadline_unix_ms: u64, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_client_free(client: *mut c_void); + fn secretspec_resolver_buffer_free(buffer: Buffer); +} + +struct CClient(*mut c_void); + +impl CClient { + fn open(peer: &Path, arguments: &[String]) -> Result { + // SAFETY: this function has no pointer arguments and returns a scalar. + if unsafe { secretspec_resolver_abi_version() } != ABI_VERSION { + return Err("C client ABI version mismatch".into()); + } + let executable = peer.to_string_lossy().into_owned().into_bytes(); + let argument_bytes = arguments + .iter() + .map(|argument| argument.as_bytes().to_vec()) + .collect::>(); + let argument_slices = argument_bytes + .iter() + .map(|argument| slice(argument)) + .collect::>(); + let initialize = + serde_json::to_vec(&initialize_params()).map_err(|error| error.to_string())?; + let options = Options { + struct_size: u32::try_from(std::mem::size_of::()).unwrap(), + abi_version: ABI_VERSION, + flags: 0, + reserved: 0, + executable: slice(&executable), + arguments: if argument_slices.is_empty() { + ptr::null() + } else { + argument_slices.as_ptr() + }, + argument_count: argument_slices.len(), + environment: ptr::null(), + environment_count: 0, + initialize_params_json: slice(&initialize), + max_stderr_bytes: 4096, + }; + let mut client = ptr::null_mut(); + let mut server = empty_buffer(); + let mut error = empty_buffer(); + // SAFETY: all input slices remain live for this call and outputs point + // to initialized writable storage. + let status = unsafe { + secretspec_resolver_client_open( + &options, + deadline_after(Duration::from_secs(2)), + &mut client, + &mut server, + &mut error, + ) + }; + free_buffer(server); + if status == STATUS_OK && !client.is_null() { + free_buffer(error); + Ok(Self(client)) + } else { + if !client.is_null() { + // SAFETY: a non-null failure output is still owned by the caller. + unsafe { secretspec_resolver_client_free(client) }; + } + Err(take_error(error, status)) + } + } + + fn cancel_pending(&self, method: &str, timeout: Duration) -> Result<(), String> { + let deadline = deadline_after(timeout); + let params = + serde_json::to_vec(&json!({"mode": "pending"})).map_err(|error| error.to_string())?; + let mut call = ptr::null_mut(); + let mut error = empty_buffer(); + // SAFETY: the client is live, input slices outlive the call, and the + // output pointers refer to initialized writable storage. + let status = unsafe { + secretspec_resolver_call_start( + self.0, + method.as_ptr(), + method.len(), + params.as_ptr(), + params.len(), + deadline, + &mut call, + &mut error, + ) + }; + if status != STATUS_OK || call.is_null() { + return Err(take_error(error, status)); + } + free_buffer(error); + // SAFETY: the call handle remains live until the matching free. + unsafe { secretspec_resolver_call_cancel(call) }; + let mut result = empty_buffer(); + let mut error = empty_buffer(); + // SAFETY: exactly one waiter consumes the live call's terminal result. + let status = unsafe { secretspec_resolver_call_wait(call, &mut result, &mut error) }; + // SAFETY: waiting is complete and no other thread uses the call. + unsafe { secretspec_resolver_call_free(call) }; + free_buffer(result); + free_buffer(error); + if status == STATUS_CANCELLED { + Ok(()) + } else { + Err(format!("C cancellation returned status {status}")) + } + } + + fn close(mut self, timeout: Duration) -> Result<(), String> { + let mut error = empty_buffer(); + // SAFETY: this is the sole close of the live client. + let status = unsafe { + secretspec_resolver_client_close(self.0, deadline_after(timeout), &mut error) + }; + // SAFETY: close made calls terminal and joined the process worker. + unsafe { secretspec_resolver_client_free(self.0) }; + self.0 = ptr::null_mut(); + if status == STATUS_OK { + free_buffer(error); + Ok(()) + } else { + Err(take_error(error, status)) + } + } +} + +impl Drop for CClient { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: emergency free accepts a live client and owns cleanup. + unsafe { secretspec_resolver_client_free(self.0) }; + self.0 = ptr::null_mut(); + } + } +} + +fn run_c(peer: &Path, scenario: Scenario) -> Result, String> { + match scenario { + Scenario::Fragmented { chunks } => { + let arguments = vec![ + "--fragment-init".into(), + chunks + .iter() + .map(usize::to_string) + .collect::>() + .join(","), + ]; + CClient::open(peer, &arguments)?.close(Duration::from_secs(2))?; + Ok(vec![ + event("initialized"), + event("frame_accepted"), + event("closed"), + ]) + } + Scenario::Rejections { peer_arguments } => { + let mut events = Vec::with_capacity(peer_arguments.len() + 1); + for arguments in peer_arguments { + if let Ok(client) = CClient::open(peer, &arguments) { + drop(client); + return Err("C client accepted an invalid initialization frame".into()); + } + events.push(event("rejected")); + } + events.push(event("closed")); + Ok(events) + } + Scenario::Lifecycle { + method, + call_timeout, + close_timeout, + } => { + let client = CClient::open(peer, &[])?; + client.cancel_pending(&method, call_timeout)?; + client.close(close_timeout)?; + Ok(vec![ + event("initialized"), + event("terminal"), + event("child_reaped"), + event("closed"), + ]) + } + } +} + +fn deadline_after(duration: Duration) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .saturating_add(duration.as_millis()) + .min(u64::MAX as u128) as u64 +} + +fn slice(bytes: &[u8]) -> Slice { + Slice { + data: bytes.as_ptr(), + size: bytes.len(), + } +} + +const fn empty_buffer() -> Buffer { + Buffer { + data: ptr::null_mut(), + size: 0, + } +} + +fn copy_buffer(buffer: Buffer) -> Vec { + if buffer.data.is_null() || buffer.size == 0 { + free_buffer(buffer); + return Vec::new(); + } + // SAFETY: C-owned buffers are valid for `size` bytes until freed below. + let bytes = unsafe { std::slice::from_raw_parts(buffer.data, buffer.size) }.to_vec(); + free_buffer(buffer); + bytes +} + +fn take_error(buffer: Buffer, status: i32) -> String { + format!( + "C status {status}: {}", + String::from_utf8_lossy(©_buffer(buffer)) + ) +} + +fn free_buffer(buffer: Buffer) { + // SAFETY: the buffer is empty or is an unconsumed C library allocation. + unsafe { secretspec_resolver_buffer_free(buffer) }; +} diff --git a/conformance/ipc/runner/src/bin/ipc-fake-peer-rust.rs b/conformance/ipc/runner/src/bin/ipc-fake-peer-rust.rs new file mode 100644 index 000000000..cc574c5f4 --- /dev/null +++ b/conformance/ipc/runner/src/bin/ipc-fake-peer-rust.rs @@ -0,0 +1,356 @@ +use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::io::{self, Read, Write}; + +const MAX_FRAME_BYTES: usize = 1_048_576; + +enum Mode { + Normal, + SilentInitialize, + FragmentInitialize(Vec), + NotifyInitialize, + RejectInitialize(Rejection), + DescendantHoldsPipes, + HoldPipes, +} + +enum Rejection { + Empty, + Batch, + DuplicateKey, + InvalidUtf8, + TruncatedHeader, + TruncatedPayload, + UnknownId, + Oversized(u32), +} + +fn main() { + if record_pid() + .and_then(|()| parse_mode()) + .and_then(serve) + .is_err() + { + std::process::exit(1); + } +} + +fn record_pid() -> Result<(), ()> { + let Some(path) = std::env::var_os("SECRETSPEC_TEST_PID_FILE") else { + return Ok(()); + }; + std::fs::write(path, std::process::id().to_string()).map_err(|_| ()) +} + +fn parse_mode() -> Result { + let mut arguments = std::env::args().skip(1); + match arguments.next().as_deref() { + None => Ok(Mode::Normal), + Some("--silent-init") if arguments.next().is_none() => Ok(Mode::SilentInitialize), + Some("--descendant-holds-pipes") if arguments.next().is_none() => { + Ok(Mode::DescendantHoldsPipes) + } + Some("--hold-pipes") if arguments.next().is_none() => Ok(Mode::HoldPipes), + Some("--notify-init") if arguments.next().is_none() => Ok(Mode::NotifyInitialize), + Some("--fragment-init") => { + let chunks = arguments + .next() + .ok_or(())? + .split(',') + .map(|value| value.parse::().map_err(|_| ())) + .collect::, _>>()?; + if chunks.is_empty() || chunks.contains(&0) || arguments.next().is_some() { + return Err(()); + } + Ok(Mode::FragmentInitialize(chunks)) + } + Some("--reject-init") => { + let rejection = match arguments.next().as_deref() { + Some("empty") => Rejection::Empty, + Some("batch") => Rejection::Batch, + Some("duplicate-key") => Rejection::DuplicateKey, + Some("invalid-utf8") => Rejection::InvalidUtf8, + Some("truncated-header") => Rejection::TruncatedHeader, + Some("truncated-payload") => Rejection::TruncatedPayload, + Some("unknown-id") => Rejection::UnknownId, + Some(value) if value.starts_with("oversized:") => Rejection::Oversized( + value + .strip_prefix("oversized:") + .ok_or(())? + .parse() + .map_err(|_| ())?, + ), + _ => return Err(()), + }; + if arguments.next().is_some() { + return Err(()); + } + Ok(Mode::RejectInitialize(rejection)) + } + Some(_) => Err(()), + } +} + +fn serve(mode: Mode) -> Result<(), ()> { + if matches!(&mode, Mode::HoldPipes) { + std::thread::sleep(std::time::Duration::from_secs(5)); + return Ok(()); + } + let mut input = io::stdin().lock(); + let mut output = io::stdout().lock(); + let mut pending = BTreeSet::new(); + let mut mode = Some(mode); + loop { + let Some(payload) = read_frame(&mut input)? else { + return Ok(()); + }; + let envelope: Value = serde_json::from_slice(&payload).map_err(|_| ())?; + let method = envelope.get("method").and_then(Value::as_str).ok_or(())?; + let id = envelope.get("id").and_then(Value::as_u64); + match method { + "rpc.initialize" => { + let id = id.ok_or(())?; + let response = initialize_response(id); + match mode.take().ok_or(())? { + Mode::Normal => write_frame(&mut output, &response)?, + Mode::SilentInitialize => {} + Mode::FragmentInitialize(chunks) => { + write_frame_fragmented(&mut output, &response, &chunks)? + } + Mode::NotifyInitialize => { + write_frame( + &mut output, + &json!({ + "jsonrpc": "2.0", + "method": "future.notice", + "params": {} + }), + )?; + write_frame(&mut output, &response)?; + } + Mode::DescendantHoldsPipes => { + write_frame(&mut output, &response)?; + std::process::Command::new(std::env::current_exe().map_err(|_| ())?) + .arg("--hold-pipes") + .spawn() + .map_err(|_| ())?; + } + Mode::HoldPipes => return Err(()), + Mode::RejectInitialize(rejection) => { + write_rejection(&mut output, rejection)?; + return Ok(()); + } + } + } + "resolver.get" => { + let id = id.ok_or(())?; + let params = envelope + .get("params") + .and_then(Value::as_object) + .ok_or(())?; + match params.get("mode").and_then(Value::as_str) { + Some("echo") => write_frame( + &mut output, + &json!({ + "jsonrpc": "2.0", + "id": id, + "result": {"echo": params.get("token").cloned().ok_or(())?} + }), + )?, + Some("pending") => { + pending.insert(id); + } + // A typed client sends real `GetParams`, which carry no + // `mode`. The declared name selects the outcome so one peer + // covers every branch of `GetResult`. + None => { + let name = params.get("name").and_then(Value::as_str).ok_or(())?; + match resolve_response(id, name) { + Some(response) => write_frame(&mut output, &response)?, + None => { + pending.insert(id); + } + } + } + _ => return Err(()), + } + } + "resolver.release" => { + let id = id.ok_or(())?; + let released = envelope + .get("params") + .and_then(|params| params.get("path_lease_ids")) + .and_then(Value::as_array) + .ok_or(())? + .len(); + write_frame( + &mut output, + &json!({"jsonrpc": "2.0", "id": id, "result": {"released": released}}), + )?; + } + "rpc.cancel" => { + if id.is_some() { + return Err(()); + } + let cancelled = envelope + .get("params") + .and_then(|params| params.get("id")) + .and_then(Value::as_u64) + .ok_or(())?; + if pending.remove(&cancelled) { + write_frame( + &mut output, + &json!({ + "jsonrpc": "2.0", + "id": cancelled, + "error": { + "code": -32003, + "message": "cancelled", + "data": {"kind": "cancelled", "retryable": false} + } + }), + )?; + } + } + "rpc.shutdown" => { + let id = id.ok_or(())?; + write_frame( + &mut output, + &json!({"jsonrpc": "2.0", "id": id, "result": {}}), + )?; + return Ok(()); + } + _ => return Err(()), + } + } +} + +/// `None` leaves the request unanswered so a caller can observe its deadline. +fn resolve_response(id: u64, name: &str) -> Option { + let result = match name { + "RESOLVED_VALUE" => json!({ + "status": "resolved", + "representation": "value", + "value": "canary-value", + "source": "provider", + "source_provider": "keyring://", + "expires_at_unix_ms": null, + "refresh_at_unix_ms": null + }), + "MISSING_REQUIRED" => json!({"status": "missing", "required": true}), + "UNDECLARED" => json!({"status": "undeclared"}), + "SILENT" => return None, + _ => { + return Some(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32005, + "message": "permission denied", + "data": {"kind": "permission_denied", "retryable": false} + } + })); + } + }; + Some(json!({"jsonrpc": "2.0", "id": id, "result": result})) +} + +fn initialize_response(id: u64) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocol": "secretspec.resolver", + "version": 1, + "server": {"name": "differential-peer", "version": "1"}, + "methods": ["resolver.get", "resolver.release"], + "capabilities": {}, + "limits": {"max_frame_bytes": 32768, "max_in_flight": 4}, + "application": {"manifest_kind": "inline", "supports_inline_manifest": true} + } + }) +} + +fn read_frame(reader: &mut impl Read) -> Result>, ()> { + let mut payload = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + let count = reader.read(&mut byte).map_err(|_| ())?; + if count == 0 { + return if payload.is_empty() { + Ok(None) + } else { + Err(()) + }; + } + if byte[0] == b'\n' { + return if payload.is_empty() { + Err(()) + } else { + Ok(Some(payload)) + }; + } + if byte[0] == b'\r' || payload.len() >= MAX_FRAME_BYTES { + return Err(()); + } + payload.push(byte[0]); + } +} + +fn write_frame(writer: &mut impl Write, value: &Value) -> Result<(), ()> { + let payload = serde_json::to_vec(value).map_err(|_| ())?; + writer.write_all(&payload).map_err(|_| ())?; + writer.write_all(b"\n").map_err(|_| ())?; + writer.flush().map_err(|_| ()) +} + +fn write_frame_fragmented( + writer: &mut impl Write, + value: &Value, + chunks: &[usize], +) -> Result<(), ()> { + let payload = serde_json::to_vec(value).map_err(|_| ())?; + let mut frame = payload; + frame.push(b'\n'); + let mut offset = 0; + for chunk in chunks { + if offset == frame.len() { + break; + } + let end = offset.saturating_add(*chunk).min(frame.len()); + writer.write_all(&frame[offset..end]).map_err(|_| ())?; + writer.flush().map_err(|_| ())?; + offset = end; + } + if offset < frame.len() { + writer.write_all(&frame[offset..]).map_err(|_| ())?; + } + writer.flush().map_err(|_| ()) +} + +fn write_rejection(writer: &mut impl Write, rejection: Rejection) -> Result<(), ()> { + match rejection { + Rejection::Empty => writer.write_all(b"\n").map_err(|_| ())?, + Rejection::Batch => write_raw_frame(writer, b"[]")?, + Rejection::DuplicateKey => { + write_raw_frame(writer, br#"{"jsonrpc":"2.0","jsonrpc":"2.0"}"#)? + } + Rejection::InvalidUtf8 => write_raw_frame(writer, &[0xff])?, + Rejection::TruncatedHeader => writer.write_all(b"{").map_err(|_| ())?, + Rejection::TruncatedPayload => { + writer.write_all(b"{}").map_err(|_| ())?; + } + Rejection::UnknownId => { + write_frame(writer, &json!({"jsonrpc": "2.0", "id": 999, "result": {}}))? + } + Rejection::Oversized(length) => writer + .write_all(&vec![b'x'; length as usize]) + .map_err(|_| ())?, + } + writer.flush().map_err(|_| ()) +} + +fn write_raw_frame(writer: &mut impl Write, payload: &[u8]) -> Result<(), ()> { + writer.write_all(payload).map_err(|_| ())?; + writer.write_all(b"\n").map_err(|_| ()) +} diff --git a/conformance/ipc/runner/src/bin/ipc-provider-conformance-driver.rs b/conformance/ipc/runner/src/bin/ipc-provider-conformance-driver.rs new file mode 100644 index 000000000..85bf8006f --- /dev/null +++ b/conformance/ipc/runner/src/bin/ipc-provider-conformance-driver.rs @@ -0,0 +1,1746 @@ +use secrecy::{ExposeSecret, SecretString}; +use secretspec::{ + Address as CoreAddress, DiscoveryContext, ExternalProvider, Provider, ProviderCredentialBroker, + ProviderCredentialRequest, ProviderEndpoint, SecretSpecError, +}; +use secretspec_ipc::lifecycle::{Environment, LaunchOptions, ProviderSession}; +use secretspec_ipc::protocol::provider::{ + self as wire, Address, AddressParams, ApplicationContext, ClearParams, ClearScope, + GetManyParams, GetManyResult, GetResult, InitializeApplication, NamedRequest, ReflectParams, + ReflectResult, ResolveAddressResult, SetExpiringParams, SetParams, +}; +use secretspec_ipc::protocol::{ + InitializeParams, InitializeResult, Limits, PROTOCOL_VERSION, PROVIDER_PROTOCOL, Product, +}; +use secretspec_ipc::{Error, ErrorKind}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const CANARY: &str = "SECRETSPEC_IPC_CANARY_DO_NOT_LOG"; +const MAX_CASE_BYTES: u64 = 1024 * 1024; +const MAX_FRAME_BYTES: usize = 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + schema_version: u32, + id: String, + targets: Vec, + timeout_ms: u64, + actions: Vec, + required_events: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EndpointProfile { + schema_version: u32, + kind: ProfileKind, + scheme: String, + uri: String, + provider_name: String, + expected_methods: Vec, + #[serde(default)] + arguments: Vec, + #[serde(default)] + environment: BTreeMap, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ProfileKind { + TransportOnly, +} + +#[derive(Clone, Copy)] +enum Implementation { + Endpoint, + Adapter, +} + +fn main() { + if let Err(error) = execute() { + eprintln!("provider conformance driver: {error}"); + std::process::exit(1); + } +} + +/// Copy the endpoint into a directory whose ACL the external adapter trusts. +/// +/// On Windows the adapter validates the endpoint executable and its parent +/// against a trust domain. A build directory is not a deployment location and +/// carries whatever ACL it inherited, so the endpoint is staged elsewhere and +/// that directory's ACL is set explicitly. Relying on what a temporary +/// directory inherits is not enough: on a CI runner that inheritance is +/// outside our control, and it has changed underneath this suite before. +/// Other platforms run the endpoint where it was built. +#[cfg(windows)] +fn stage_endpoint(endpoint: &Path) -> Result, String> { + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + secretspec::windows_security::make_path_private(directory.path()) + .map_err(|error| format!("failed to secure the staging directory: {error}"))?; + let name = endpoint + .file_name() + .ok_or_else(|| "endpoint path has no file name".to_string())?; + let staged = directory.path().join(name); + std::fs::copy(endpoint, &staged).map_err(|error| error.to_string())?; + Ok(Some((directory, staged))) +} + +#[cfg(not(windows))] +fn stage_endpoint(_endpoint: &Path) -> Result, String> { + Ok(None) +} +fn execute() -> Result<(), String> { + let (implementation, endpoint, profile) = arguments()?; + if profile.is_some() && !matches!(implementation, Implementation::Endpoint) { + return Err("transport-only profiles require --implementation endpoint".into()); + } + let staged = stage_endpoint(&endpoint)?; + let endpoint = match &staged { + Some((_directory, path)) => path.clone(), + None => endpoint, + }; + let mut input = Vec::new(); + io::stdin() + .take(MAX_CASE_BYTES + 1) + .read_to_end(&mut input) + .map_err(|error| error.to_string())?; + if input.len() as u64 > MAX_CASE_BYTES { + return Err("conformance case exceeds 1 MiB".into()); + } + let case: Case = serde_json::from_slice(&input).map_err(|error| error.to_string())?; + validate_case(&case)?; + if let Some(profile) = &profile + && profile.kind == ProfileKind::TransportOnly + && !case.id.starts_with("wire.") + { + serde_json::to_writer( + io::stdout().lock(), + &json!({ + "case": case.id, + "status": "not_applicable", + "reason": "transport-only endpoint profile does not declare provider fixtures", + "events": [] + }), + ) + .map_err(|error| error.to_string())?; + return Ok(()); + } + let events = match case.id.as_str() { + "wire.fragmented-frame" => match implementation { + Implementation::Endpoint => run_fragmented_server(&endpoint, &case, profile.as_ref())?, + Implementation::Adapter => run_fragmented_adapter(&endpoint, &case)?, + }, + "wire.strict-rejections" => match implementation { + Implementation::Endpoint => { + run_strict_server_rejections(&endpoint, &case, profile.as_ref())? + } + Implementation::Adapter => run_strict_adapter_rejections(&endpoint, &case)?, + }, + "wire.initialization-state" if matches!(implementation, Implementation::Endpoint) => { + run_initialization_state(&endpoint, &case, profile.as_ref())? + } + "wire.notifications" if matches!(implementation, Implementation::Endpoint) => { + run_notifications(&endpoint, &case, profile.as_ref())? + } + "wire.lifecycle" if matches!(implementation, Implementation::Endpoint) => { + run_wire_lifecycle(&endpoint, &case, profile.as_ref())? + } + "provider.operations" => { + require_methods( + &case, + &[ + "provider.resolve_address", + "provider.get", + "provider.get_many", + "provider.exists", + "provider.set", + "provider.set_expiring", + "provider.delete", + "provider.clear", + "provider.check_writable", + "provider.check_deletable", + "provider.describe_write_target", + "provider.reflect", + ], + )?; + match implementation { + Implementation::Endpoint => run_async(run_endpoint_operations(&endpoint))?, + Implementation::Adapter => run_adapter_operations(&endpoint)?, + } + } + "provider.lifecycle" if matches!(implementation, Implementation::Endpoint) => { + require_action(&case.actions, "cancel")?; + require_action(&case.actions, "deadline")?; + run_async(run_endpoint_lifecycle(&endpoint))? + } + "provider.reconnect" if matches!(implementation, Implementation::Adapter) => { + require_action(&case.actions, "crash")?; + require_action(&case.actions, "reconnect")?; + run_adapter_reconnect(&endpoint)? + } + "provider.errors" => { + require_action(&case.actions, "explicit_retry")?; + match implementation { + Implementation::Endpoint => run_async(run_endpoint_errors(&endpoint))?, + Implementation::Adapter => run_adapter_errors(&endpoint)?, + } + } + "provider.session-isolation" if matches!(implementation, Implementation::Adapter) => { + require_action(&case.actions, "change_reason")?; + run_adapter_session_isolation(&endpoint)? + } + other => return Err(format!("unsupported provider conformance case {other}")), + }; + serde_json::to_writer( + io::stdout().lock(), + &json!({"case": case.id, "events": events}), + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn arguments() -> Result<(Implementation, PathBuf, Option), String> { + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--implementation")) { + return Err("expected --implementation endpoint|adapter --endpoint PATH".into()); + } + let implementation = match arguments.next().and_then(|value| value.into_string().ok()) { + Some(value) if value == "endpoint" => Implementation::Endpoint, + Some(value) if value == "adapter" => Implementation::Adapter, + _ => return Err("implementation must be endpoint or adapter".into()), + }; + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--endpoint")) { + return Err("expected --endpoint PATH".into()); + } + let endpoint = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing endpoint path".to_string())?; + let profile = match arguments.next() { + None => None, + Some(argument) if argument == "--profile" => { + let path = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing profile path".to_string())?; + let bytes = std::fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let profile: EndpointProfile = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + validate_profile(&profile)?; + Some(profile) + } + Some(_) => return Err("expected optional --profile PATH".into()), + }; + if arguments.next().is_some() { + return Err("unexpected driver argument".into()); + } + Ok((implementation, endpoint, profile)) +} + +fn validate_profile(profile: &EndpointProfile) -> Result<(), String> { + if profile.schema_version != 1 + || profile.scheme.is_empty() + || profile.provider_name.is_empty() + || profile.expected_methods.is_empty() + || profile + .expected_methods + .iter() + .any(|method| method.is_empty() || method.len() > 256) + || profile + .expected_methods + .iter() + .collect::>() + .len() + != profile.expected_methods.len() + || !profile.uri.starts_with(&format!("{}://", profile.scheme)) + || profile + .environment + .keys() + .any(|name| name.is_empty() || name.contains('=') || name.contains('\0')) + || profile + .arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err("invalid endpoint profile".into()); + } + Ok(()) +} + +fn validate_case(case: &Case) -> Result<(), String> { + if case.schema_version != 1 + || case.targets.is_empty() + || case.timeout_ms == 0 + || case.actions.is_empty() + || case.required_events.is_empty() + { + return Err("invalid conformance case envelope".into()); + } + Ok(()) +} + +fn require_action<'a>(actions: &'a [Value], kind: &str) -> Result<&'a Value, String> { + actions + .iter() + .find(|action| action.get("kind").and_then(Value::as_str) == Some(kind)) + .ok_or_else(|| format!("case has no {kind} action")) +} + +fn require_methods(case: &Case, methods: &[&str]) -> Result<(), String> { + let present = case + .actions + .iter() + .filter(|action| action.get("kind").and_then(Value::as_str) == Some("call")) + .filter_map(|action| action.get("method").and_then(Value::as_str)) + .collect::>(); + if let Some(missing) = methods.iter().find(|method| !present.contains(**method)) { + return Err(format!("case has no call for {missing}")); + } + Ok(()) +} + +fn event(kind: &str) -> Value { + json!({"kind": kind}) +} + +fn events(kinds: &[&str]) -> Vec { + kinds.iter().map(|kind| event(kind)).collect() +} + +fn deadline_after(duration: Duration) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .saturating_add(duration.as_millis()) + .min(u64::MAX as u128) as u64 +} + +fn initialize_params() -> InitializeParams { + initialize_params_for(None) +} + +fn initialize_params_for( + profile: Option<&EndpointProfile>, +) -> InitializeParams { + let (scheme, uri) = profile + .map(|profile| (profile.scheme.clone(), profile.uri.clone())) + .unwrap_or_else(|| ("memory".into(), "memory://conformance".into())); + InitializeParams { + protocol: PROVIDER_PROTOCOL.into(), + versions: vec![PROTOCOL_VERSION], + client: Product { + name: "provider-conformance".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 8, + }, + client_methods: Vec::new(), + application: InitializeApplication { + scheme, + uri, + context: ApplicationContext { + project: Some("conformance".into()), + profile: Some("default".into()), + base_dir: None, + reason: Some("conformance".into()), + requested_authorization_duration_ms: Some(8 * 60 * 60 * 1_000), + }, + }, + } +} + +fn launch_options(endpoint: &Path, arguments: &[String]) -> LaunchOptions { + LaunchOptions { + executable: endpoint.to_path_buf(), + arguments: arguments.iter().map(Into::into).collect(), + environment: Environment::Replace(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 4096, + } +} + +async fn open_endpoint(endpoint: &Path, arguments: &[String]) -> Result { + let initialize = initialize_params(); + let session = ProviderSession::launch( + launch_options(endpoint, arguments), + initialize.client, + initialize.limits, + initialize.application, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if session.metadata().name != "memory" { + return Err("provider endpoint returned the wrong identity".into()); + } + Ok(session) +} + +fn wire_address(key: &str) -> Address { + Address::Convention { + project: "payments".into(), + profile: "production".into(), + key: key.into(), + } +} + +fn wire_other_address(key: &str) -> Address { + Address::Convention { + project: "payments".into(), + profile: "development".into(), + key: key.into(), + } +} + +async fn run_endpoint_operations(endpoint: &Path) -> Result, String> { + let session = open_endpoint(endpoint, &[]).await?; + let client = &session; + let resolved: ResolveAddressResult = client + .call( + "provider.resolve_address", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if resolved.coordinates.item != "payments/production/TOKEN" { + return Err("provider resolved the wrong convention address".into()); + } + + assert_missing( + client + .call( + "provider.get", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?, + )?; + call_empty(client, "provider.check_writable", wire_address("TOKEN")).await?; + call_empty(client, "provider.check_deletable", wire_address("TOKEN")).await?; + let described: wire::DescribeWriteTargetResult = client + .call( + "provider.describe_write_target", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if described.description.contains(CANARY) || described.description.is_empty() { + return Err("provider returned an unsafe write description".into()); + } + + set_wire(client, wire_address("TOKEN"), CANARY).await?; + let found: GetResult = client + .call( + "provider.get", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if found + != (GetResult::Found { + value: CANARY.into(), + expires_at_unix_ms: None, + }) + { + return Err("provider did not return the stored value".into()); + } + set_wire(client, wire_address("SECRET_EXPIRY"), CANARY).await?; + let validity_bounded: GetResult = client + .call( + "provider.get", + &AddressParams { + address: wire_address("SECRET_EXPIRY"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !matches!( + validity_bounded, + GetResult::Found { + expires_at_unix_ms: Some(_), + .. + } + ) { + return Err("provider did not report the secret expiry".into()); + } + let removed_validity_fixture: wire::DeletedResult = client + .call( + "provider.delete", + &AddressParams { + address: wire_address("SECRET_EXPIRY"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !removed_validity_fixture.deleted { + return Err("provider could not remove the validity fixture".into()); + } + let batch: GetManyResult = client + .call( + "provider.get_many", + &GetManyParams { + requests: vec![ + NamedRequest { + name: "token".into(), + address: wire_address("TOKEN"), + }, + NamedRequest { + name: "missing".into(), + address: wire_address("MISSING"), + }, + ], + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if batch.results.len() != 2 + || batch.results[0].name != "token" + || batch.results[1].name != "missing" + { + return Err("provider batch result did not preserve request order".into()); + } + let exists: wire::ExistsResult = client + .call( + "provider.exists", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !exists.exists { + return Err("provider presence check missed a stored value".into()); + } + + let stored: wire::StoredResult = client + .call( + "provider.set_expiring", + &SetExpiringParams { + address: wire_address("TOKEN"), + value: CANARY.into(), + ttl_ms: 250, + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !stored.stored { + return Err("provider did not confirm the expiring write".into()); + } + let retained: GetResult = client + .call( + "provider.get", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !matches!(retained, GetResult::Found { .. }) { + return Err("provider did not retain the expiring store entry".into()); + } + tokio::time::sleep(Duration::from_millis(350)).await; + let expired: GetResult = client + .call( + "provider.get", + &AddressParams { + address: wire_address("TOKEN"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + assert_missing(expired)?; + + set_wire(client, wire_address("A"), CANARY).await?; + set_wire(client, wire_address("B"), CANARY).await?; + set_wire(client, wire_other_address("OTHER"), CANARY).await?; + let cleared: wire::ClearResult = client + .call( + "provider.clear", + &ClearParams { + scope: ClearScope::Convention { + project: "payments".into(), + profile: "production".into(), + }, + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if cleared.cleared != 2 { + return Err("provider clear escaped or under-cleared its namespace".into()); + } + let other_exists: wire::ExistsResult = client + .call( + "provider.exists", + &AddressParams { + address: wire_other_address("OTHER"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !other_exists.exists { + return Err("provider clear escaped the selected namespace".into()); + } + + set_wire(client, wire_address("DELETE"), CANARY).await?; + let first: wire::DeletedResult = client + .call( + "provider.delete", + &AddressParams { + address: wire_address("DELETE"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + let second: wire::DeletedResult = client + .call( + "provider.delete", + &AddressParams { + address: wire_address("DELETE"), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if !first.deleted || second.deleted { + return Err("provider delete is not idempotent".into()); + } + let reflected: ReflectResult = client + .call( + "provider.reflect", + &ReflectParams { + project: "payments".into(), + profile: "production".into(), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if reflected.declarations.len() != 1 { + return Err("provider reflection returned the wrong declarations".into()); + } + session + .close(deadline_after(Duration::from_secs(2))) + .await + .map_err(stable)?; + Ok(operation_events()) +} + +async fn call_empty( + client: &ProviderSession, + method: &str, + address: Address, +) -> Result<(), String> { + let value: Value = client + .call( + method, + &AddressParams { address }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if value != json!({}) { + return Err(format!("{method} returned a non-empty result")); + } + Ok(()) +} + +async fn set_wire(client: &ProviderSession, address: Address, value: &str) -> Result<(), String> { + let stored: wire::StoredResult = client + .call( + "provider.set", + &SetParams { + address, + value: value.into(), + }, + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(stable)?; + if stored.stored { + Ok(()) + } else { + Err("provider did not confirm the write".into()) + } +} + +fn assert_missing(result: GetResult) -> Result<(), String> { + if result == GetResult::Missing { + Ok(()) + } else { + Err("provider returned a value for a missing or expired entry".into()) + } +} + +fn core_address(key: &str) -> CoreAddress<'_> { + CoreAddress::Convention { + project: "payments", + profile: "production", + key, + } +} + +fn core_other_address(key: &str) -> CoreAddress<'_> { + CoreAddress::Convention { + project: "payments", + profile: "development", + key, + } +} + +fn external_provider(endpoint: &Path, arguments: Vec) -> Result { + external_provider_with_uri(endpoint, arguments, "memory://conformance") +} + +fn external_provider_with_uri( + endpoint: &Path, + arguments: Vec, + uri: &str, +) -> Result { + ExternalProvider::new( + ProviderEndpoint { + scheme: "memory".into(), + executable: endpoint.to_path_buf(), + arguments, + }, + uri, + ) + .map_err(|error| error.to_string()) +} + +fn run_adapter_operations(endpoint: &Path) -> Result, String> { + struct ConformanceBroker(AtomicBool); + impl ProviderCredentialBroker for ConformanceBroker { + fn get( + &self, + scheme: &str, + request: &ProviderCredentialRequest, + ) -> secretspec::Result> { + if scheme != "memory" + || request.name != "conformance_token" + || request.scope != "memory://conformance" + { + return Err(SecretSpecError::ProviderOperationFailed( + "provider sent an unexpected credential request".into(), + )); + } + self.0.store(true, Ordering::Release); + Ok(Some(SecretString::from( + "conformance-credential".to_string(), + ))) + } + } + + let broker = Arc::new(ConformanceBroker(AtomicBool::new(false))); + let mut provider = external_provider(endpoint, Vec::new())?; + provider.with_credential_broker(broker.clone()); + let resolved = provider + .convention_address("payments", "production", "TOKEN") + .map_err(|error| error.to_string())?; + if !broker.0.load(Ordering::Acquire) { + return Err("external adapter did not broker the endpoint credential request".into()); + } + if resolved.render() != "item=payments/production/TOKEN" { + return Err("external adapter resolved the wrong convention address".into()); + } + if provider + .get(core_address("TOKEN")) + .map_err(|error| error.to_string())? + .is_some() + { + return Err("external adapter found a missing value".into()); + } + provider + .check_writable(core_address("TOKEN")) + .map_err(|error| error.to_string())?; + provider + .check_deletable(core_address("TOKEN")) + .map_err(|error| error.to_string())?; + let description = provider + .describe_write_target(core_address("TOKEN")) + .map_err(|error| error.to_string())?; + if description.is_empty() || description.contains(CANARY) { + return Err("external adapter returned an unsafe write description".into()); + } + let secret = SecretString::from(CANARY.to_string()); + provider + .set(core_address("TOKEN"), &secret) + .map_err(|error| error.to_string())?; + let found = provider + .get(core_address("TOKEN")) + .map_err(|error| error.to_string())? + .ok_or_else(|| "external adapter missed a stored value".to_string())?; + if found.expose_secret() != CANARY { + return Err("external adapter returned the wrong stored value".into()); + } + let batch = provider + .get_many(&[ + ("token", core_address("TOKEN")), + ("missing", core_address("MISSING")), + ]) + .map_err(|error| error.to_string())?; + if batch.len() != 1 || !batch.contains_key("token") { + return Err("external adapter batch result is incorrect".into()); + } + if !provider + .exists(core_address("TOKEN")) + .map_err(|error| error.to_string())? + { + return Err("external adapter presence check missed a stored value".into()); + } + provider + .set(core_address("SECRET_EXPIRY"), &secret) + .map_err(|error| error.to_string())?; + let validity_bounded = provider + .get_with_metadata(core_address("SECRET_EXPIRY")) + .map_err(|error| error.to_string())? + .ok_or_else(|| "external adapter missed a validity-bounded value".to_string())?; + if validity_bounded.expires_at_unix_ms.is_none() { + return Err("external adapter dropped the provider-reported expiry".into()); + } + if !provider + .delete(core_address("SECRET_EXPIRY")) + .map_err(|error| error.to_string())? + { + return Err("external adapter could not remove the validity fixture".into()); + } + provider + .set_expiring(core_address("TOKEN"), &secret, Duration::from_millis(250)) + .map_err(|error| error.to_string())?; + let retained = provider + .get_with_metadata(core_address("TOKEN")) + .map_err(|error| error.to_string())? + .ok_or_else(|| "external adapter missed an expiring value".to_string())?; + if retained.expires_at_unix_ms.is_some() { + return Err("external adapter confused store retention with secret validity".into()); + } + std::thread::sleep(Duration::from_millis(350)); + if provider + .get(core_address("TOKEN")) + .map_err(|error| error.to_string())? + .is_some() + { + return Err("external adapter retained an expired value".into()); + } + + provider + .set(core_address("A"), &secret) + .map_err(|error| error.to_string())?; + provider + .set(core_address("B"), &secret) + .map_err(|error| error.to_string())?; + provider + .set(core_other_address("OTHER"), &secret) + .map_err(|error| error.to_string())?; + let cleared = provider + .clear(ClearScope::Convention { + project: "payments".into(), + profile: "production".into(), + }) + .map_err(|error| error.to_string())?; + if cleared != 2 + || !provider + .exists(core_other_address("OTHER")) + .map_err(|error| error.to_string())? + { + return Err("external adapter clear escaped or under-cleared its namespace".into()); + } + + provider + .set(core_address("DELETE"), &secret) + .map_err(|error| error.to_string())?; + if !provider + .delete(core_address("DELETE")) + .map_err(|error| error.to_string())? + || provider + .delete(core_address("DELETE")) + .map_err(|error| error.to_string())? + { + return Err("external adapter delete is not idempotent".into()); + } + let reflected = provider + .reflect(DiscoveryContext::new("payments", "production")) + .map_err(|error| error.to_string())?; + if reflected.len() != 1 { + return Err("external adapter reflection returned the wrong declarations".into()); + } + drop(provider); + Ok(operation_events()) +} + +fn operation_events() -> Vec { + events(&[ + "initialized", + "resolved_address", + "read", + "secret_expiry_reported", + "batched", + "preflighted", + "mutated", + "expired", + "cleared", + "reflected", + "closed", + ]) +} + +async fn run_endpoint_lifecycle(endpoint: &Path) -> Result, String> { + let session = open_endpoint(endpoint, &[]).await?; + let client = &session; + let cancel_deadline = deadline_after(Duration::from_secs(2)); + let mut cancelled = client + .raw() + .start( + "provider.get", + &AddressParams { + address: wire_address("__BLOCK__"), + }, + cancel_deadline, + ) + .await + .map_err(stable)?; + cancelled.cancel().await.map_err(stable)?; + if !matches!(cancelled.wait().await, Err(Error::Cancelled)) { + return Err("provider cancellation did not produce one cancelled terminal".into()); + } + + let expiry = deadline_after(Duration::from_millis(50)); + let mut expired = client + .raw() + .start( + "provider.get", + &AddressParams { + address: wire_address("__BLOCK__"), + }, + expiry, + ) + .await + .map_err(stable)?; + if !matches!(expired.wait().await, Err(Error::DeadlineExceeded)) { + return Err("provider deadline did not produce one deadline terminal".into()); + } + session + .close(deadline_after(Duration::from_secs(2))) + .await + .map_err(stable)?; + Ok(events(&[ + "initialized", + "cancelled", + "deadline_exceeded", + "terminal", + "closed", + ])) +} + +fn run_adapter_reconnect(endpoint: &Path) -> Result, String> { + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + let marker = directory.path().join("crashed-once"); + let provider = external_provider( + endpoint, + vec![ + "--crash-on-get-once".into(), + marker.to_string_lossy().into_owned(), + ], + )?; + if provider.get(core_address("TOKEN")).is_ok() || !marker.exists() { + return Err("external adapter did not observe the endpoint crash".into()); + } + if provider + .get(core_address("TOKEN")) + .map_err(|error| error.to_string())? + .is_some() + { + return Err("external adapter replayed or invented a value after reconnect".into()); + } + drop(provider); + Ok(events(&["initialized", "crashed", "reconnected", "closed"])) +} + +async fn run_endpoint_errors(endpoint: &Path) -> Result, String> { + let session = open_endpoint(endpoint, &[]).await?; + let client = &session; + for (key, expected) in [ + ("__INTERACTION_REQUIRED__", ErrorKind::InteractionRequired), + ("__PERMISSION_DENIED__", ErrorKind::PermissionDenied), + ("__UNAVAILABLE__", ErrorKind::Unavailable), + ] { + let result = client + .call::<_, GetResult>( + "provider.get", + &AddressParams { + address: wire_address(key), + }, + deadline_after(Duration::from_secs(2)), + ) + .await; + match result { + Err(error) if error.rpc_kind() == Some(expected) => {} + _ => return Err(format!("provider endpoint did not preserve {expected}")), + } + } + let conflict = client + .call::<_, wire::StoredResult>( + "provider.set", + &SetParams { + address: wire_address("__CONFLICT__"), + value: CANARY.into(), + }, + deadline_after(Duration::from_secs(2)), + ) + .await; + if !matches!(conflict, Err(ref error) if error.rpc_kind() == Some(ErrorKind::Conflict)) { + return Err("provider endpoint did not preserve conflict".into()); + } + session + .close(deadline_after(Duration::from_secs(2))) + .await + .map_err(stable)?; + Ok(error_events()) +} + +fn provider_protocol_kind(error: SecretSpecError) -> Option { + match error { + SecretSpecError::ProviderProtocol { kind, .. } => Some(kind), + _ => None, + } +} + +fn run_adapter_errors(endpoint: &Path) -> Result, String> { + let provider = external_provider(endpoint, Vec::new())?; + for (key, expected) in [ + ("__INTERACTION_REQUIRED__", ErrorKind::InteractionRequired), + ("__PERMISSION_DENIED__", ErrorKind::PermissionDenied), + ("__UNAVAILABLE__", ErrorKind::Unavailable), + ] { + let error = provider + .get(core_address(key)) + .expect_err("one-shot provider error was automatically replayed"); + if provider_protocol_kind(error) != Some(expected) { + return Err(format!("external adapter did not preserve {expected}")); + } + } + let error = provider + .set( + core_address("__CONFLICT__"), + &SecretString::from(CANARY.to_string()), + ) + .expect_err("one-shot provider conflict was automatically replayed"); + if provider_protocol_kind(error) != Some(ErrorKind::Conflict) { + return Err("external adapter did not preserve conflict".into()); + } + drop(provider); + Ok(error_events()) +} + +fn error_events() -> Vec { + events(&[ + "initialized", + "interaction_required", + "permission_denied", + "conflict", + "unavailable", + "not_replayed", + "closed", + ]) +} + +fn run_adapter_session_isolation(endpoint: &Path) -> Result, String> { + let first = external_provider_with_uri(endpoint, Vec::new(), "memory://session-a")?; + first.set_reason(Some("session-a".into())); + first + .set( + core_address("SHARED"), + &SecretString::from(CANARY.to_string()), + ) + .map_err(|error| error.to_string())?; + drop(first); + + let second = external_provider_with_uri(endpoint, Vec::new(), "memory://session-b")?; + second.set_reason(Some("session-b".into())); + if second + .get(core_address("SHARED")) + .map_err(|error| error.to_string())? + .is_some() + { + return Err("provider state crossed configured URI sessions".into()); + } + second + .set( + core_address("SHARED"), + &SecretString::from(CANARY.to_string()), + ) + .map_err(|error| error.to_string())?; + second.set_reason(Some("session-b-reason-2".into())); + if second + .get(core_address("SHARED")) + .map_err(|error| error.to_string())? + .is_some() + { + return Err("provider state crossed reason-bound sessions".into()); + } + drop(second); + Ok(events(&[ + "session_a_initialized", + "session_a_closed", + "session_b_initialized", + "uri_isolated", + "reason_isolated", + "closed", + ])) +} + +fn run_async(future: F) -> Result, String> +where + F: std::future::Future, String>>, +{ + tokio::runtime::Runtime::new() + .map_err(|error| error.to_string())? + .block_on(future) +} + +fn stable(error: secretspec_ipc::Error) -> String { + error.stable_message().to_string() +} + +fn run_fragmented_server( + endpoint: &Path, + case: &Case, + profile: Option<&EndpointProfile>, +) -> Result, String> { + require_action(&case.actions, "launch")?; + require_action(&case.actions, "shutdown")?; + let chunks = require_action(&case.actions, "peer_write")? + .get("chunks") + .and_then(Value::as_array) + .ok_or_else(|| "fragmented case has no chunks".to_string())? + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "invalid fragment size".to_string()) + }) + .collect::, _>>()?; + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child + .stdin + .take() + .ok_or_else(|| "endpoint stdin was not piped".to_string())?; + let mut output = child + .stdout + .take() + .ok_or_else(|| "endpoint stdout was not piped".to_string())?; + let initialize = frame(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "rpc.initialize", + "_meta": {"deadline_unix_ms": deadline_after(Duration::from_secs(2))}, + "params": initialize_params_for(profile), + }))?; + write_fragmented(&mut input, &initialize, &chunks)?; + let response = read_json_frame(&mut output)?; + if response.get("id").and_then(Value::as_u64) != Some(1) { + return Err("provider endpoint rejected fragmented initialization".into()); + } + expect_initialized(&response, profile)?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "rpc.shutdown", + "_meta": {"deadline_unix_ms": deadline_after(Duration::from_secs(2))}, + "params": {}, + }), + )?; + let shutdown = read_json_frame(&mut output)?; + if shutdown.get("result") != Some(&json!({})) { + return Err("provider endpoint returned an invalid shutdown response".into()); + } + drop(input); + if !child.wait().map_err(|error| error.to_string())?.success() { + return Err("provider endpoint failed after fragmented initialization".into()); + } + Ok(events(&["initialized", "frame_accepted", "closed"])) +} + +fn run_fragmented_adapter(endpoint: &Path, case: &Case) -> Result, String> { + require_action(&case.actions, "launch")?; + require_action(&case.actions, "shutdown")?; + let chunks = require_action(&case.actions, "peer_write")? + .get("chunks") + .and_then(Value::as_array) + .ok_or_else(|| "fragmented case has no chunks".to_string())? + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "invalid fragment size".to_string()) + }) + .collect::, _>>()?; + let provider = external_provider( + endpoint, + vec![ + "--fragment-init".into(), + chunks + .iter() + .map(usize::to_string) + .collect::>() + .join(","), + ], + )?; + if !provider.supports_delete() { + return Err("external adapter rejected fragmented initialization".into()); + } + drop(provider); + Ok(events(&["initialized", "frame_accepted", "closed"])) +} + +fn raw_initialize(profile: Option<&EndpointProfile>, id: u64) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "rpc.initialize", + "_meta": {"deadline_unix_ms": deadline_after(Duration::from_secs(2))}, + "params": initialize_params_for(profile), + }) +} + +fn expect_initialized(response: &Value, profile: Option<&EndpointProfile>) -> Result<(), String> { + let result = response + .get("result") + .ok_or_else(|| "endpoint rejected valid initialization".to_string())?; + let initialized: InitializeResult = serde_json::from_value(result.clone()) + .map_err(|error| format!("invalid initialize result: {error}"))?; + initialized + .validate_common( + PROVIDER_PROTOCOL, + &[PROTOCOL_VERSION], + Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 8, + }, + ) + .map_err(|error| error.stable_message().to_string())?; + let Some(profile) = profile else { + return Ok(()); + }; + if initialized.application["provider"]["name"] != profile.provider_name { + return Err("provider endpoint returned the wrong configured identity".into()); + } + let methods = initialized.methods.into_iter().collect::>(); + let expected = profile + .expected_methods + .iter() + .cloned() + .collect::>(); + if methods != expected { + return Err("provider endpoint returned methods that differ from its profile".into()); + } + Ok(()) +} + +fn expect_error_kind(response: &Value, kind: &str) -> Result<(), String> { + if response["error"]["data"]["kind"] != kind || response["error"]["data"].get("value").is_some() + { + return Err(format!("expected value-free {kind} response")); + } + Ok(()) +} + +fn finish_closed(mut child: Child, input: impl Write, mut output: impl Read) -> Result<(), String> { + drop(input); + let mut trailing = Vec::new(); + output + .read_to_end(&mut trailing) + .map_err(|error| error.to_string())?; + if !trailing.is_empty() { + return Err("endpoint wrote more than one terminal response".into()); + } + if !child.wait().map_err(|error| error.to_string())?.success() { + return Err("endpoint failed while closing a rejected session".into()); + } + Ok(()) +} + +fn run_initialization_state( + endpoint: &Path, + case: &Case, + profile: Option<&EndpointProfile>, +) -> Result, String> { + require_action(&case.actions, "application_before_initialize")?; + require_action(&case.actions, "response_before_initialize")?; + require_action(&case.actions, "second_initialize")?; + require_action(&case.actions, "unsupported_version")?; + require_action(&case.actions, "invalid_params")?; + + // Application request before initialization. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc":"2.0", "id":1, "method":"provider.get", + "_meta":{"deadline_unix_ms":deadline_after(Duration::from_secs(2))}, + "params":{} + }), + )?; + expect_error_kind(&read_json_frame(&mut output)?, "invalid_request")?; + finish_closed(child, input, output)?; + } + + // An unmatched response has no response channel and closes immediately. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &json!({"jsonrpc":"2.0", "id":1, "result":{}}))?; + finish_closed(child, input, output)?; + } + + // Invalid parameters and unsupported versions have distinct terminal + // errors, and neither leaves a partially initialized connection alive. + for (mut initialize, expected) in [ + ( + { + let mut value = raw_initialize(profile, 1); + value["params"]["limits"]["max_in_flight"] = json!(0); + value + }, + "invalid_params", + ), + ( + { + let mut value = raw_initialize(profile, 1); + value["params"]["versions"] = json!([u32::MAX]); + value + }, + "unsupported_version", + ), + ] { + initialize["_meta"]["deadline_unix_ms"] = json!(deadline_after(Duration::from_secs(2))); + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &initialize)?; + expect_error_kind(&read_json_frame(&mut output)?, expected)?; + finish_closed(child, input, output)?; + } + + // A second initialize after readiness is terminal. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &raw_initialize(profile, 1))?; + expect_initialized(&read_json_frame(&mut output)?, profile)?; + write_json_frame(&mut input, &raw_initialize(profile, 2))?; + expect_error_kind(&read_json_frame(&mut output)?, "invalid_request")?; + finish_closed(child, input, output)?; + } + + Ok(events(&[ + "application_before_initialize_rejected", + "response_before_initialize_closed", + "second_initialize_rejected", + "unsupported_version_rejected", + "invalid_params_rejected", + "closed", + ])) +} + +fn run_notifications( + endpoint: &Path, + case: &Case, + profile: Option<&EndpointProfile>, +) -> Result, String> { + require_action(&case.actions, "unknown_method")?; + require_action(&case.actions, "malformed_cancel")?; + require_action(&case.actions, "unknown_cancel_id")?; + require_action(&case.actions, "terminal_cancel_id")?; + require_action(&case.actions, "unknown_member")?; + + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &raw_initialize(profile, 1))?; + expect_initialized(&read_json_frame(&mut output)?, profile)?; + + for (notification, request_id) in [ + ( + json!({"jsonrpc":"2.0","method":"future.notice","params":{}}), + 2, + ), + ( + json!({"jsonrpc":"2.0","method":"rpc.cancel","params":{"id":"bad"}}), + 3, + ), + ( + json!({"jsonrpc":"2.0","method":"rpc.cancel","params":{"id":999}}), + 4, + ), + ( + json!({"jsonrpc":"2.0","method":"rpc.cancel","params":{"id":1}}), + 5, + ), + ] { + write_json_frame(&mut input, ¬ification)?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc":"2.0", "id":request_id, "method":"rpc.discover", + "_meta":{"deadline_unix_ms":deadline_after(Duration::from_secs(2))}, + "params":{} + }), + )?; + let response = read_json_frame(&mut output)?; + if response.get("id").and_then(Value::as_u64) != Some(request_id) + || response.get("result").is_none() + { + return Err("ignored notification made the session unusable".into()); + } + } + + write_json_frame( + &mut input, + &json!({"jsonrpc":"2.0","method":"future.notice","params":{},"extra":true}), + )?; + expect_error_kind(&read_json_frame(&mut output)?, "invalid_request")?; + finish_closed(child, input, output)?; + Ok(events(&[ + "unknown_notification_ignored", + "malformed_cancel_ignored", + "unknown_cancel_ignored", + "terminal_cancel_ignored", + "unknown_member_rejected", + "closed", + ])) +} + +fn run_wire_lifecycle( + endpoint: &Path, + case: &Case, + profile: Option<&EndpointProfile>, +) -> Result, String> { + require_action(&case.actions, "initialize")?; + require_action(&case.actions, "shutdown")?; + require_action(&case.actions, "disconnect")?; + require_action(&case.actions, "reconnect")?; + require_action(&case.actions, "unknown_method")?; + + // An orderly shutdown produces one response and then EOF. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &raw_initialize(profile, 1))?; + expect_initialized(&read_json_frame(&mut output)?, profile)?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc":"2.0", "id":2, "method":"provider.future_method", + "_meta":{"deadline_unix_ms":deadline_after(Duration::from_secs(2))}, + "params":{} + }), + )?; + expect_error_kind(&read_json_frame(&mut output)?, "capability_required")?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc":"2.0", "id":3, "method":"rpc.shutdown", + "_meta":{"deadline_unix_ms":deadline_after(Duration::from_secs(2))}, + "params":{} + }), + )?; + let response = read_json_frame(&mut output)?; + if response.get("id").and_then(Value::as_u64) != Some(3) + || response.get("result") != Some(&json!({})) + { + return Err("endpoint returned an invalid shutdown response".into()); + } + finish_closed(child, input, output)?; + } + + // Losing the private transport cleans up this session without requiring a + // provider-specific crash hook. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &raw_initialize(profile, 1))?; + let mut output = output; + expect_initialized(&read_json_frame(&mut output)?, profile)?; + finish_closed(child, input, output)?; + } + + // A fresh process must initialize successfully after either terminal path. + { + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child.stdin.take().ok_or("endpoint stdin was not piped")?; + let mut output = child.stdout.take().ok_or("endpoint stdout was not piped")?; + write_json_frame(&mut input, &raw_initialize(profile, 1))?; + expect_initialized(&read_json_frame(&mut output)?, profile)?; + write_json_frame( + &mut input, + &json!({ + "jsonrpc":"2.0", "id":2, "method":"rpc.shutdown", + "_meta":{"deadline_unix_ms":deadline_after(Duration::from_secs(2))}, + "params":{} + }), + )?; + if read_json_frame(&mut output)?.get("result") != Some(&json!({})) { + return Err("reconnected endpoint rejected shutdown".into()); + } + finish_closed(child, input, output)?; + } + + Ok(events(&[ + "initialized", + "capability_gated", + "shutdown", + "disconnect_cleaned_up", + "reconnected", + "closed", + ])) +} + +fn run_strict_server_rejections( + endpoint: &Path, + case: &Case, + profile: Option<&EndpointProfile>, +) -> Result, String> { + let actions = case + .actions + .iter() + .filter(|action| action.get("kind").and_then(Value::as_str) == Some("raw_frame")) + .collect::>(); + if actions.is_empty() { + return Err("strict rejection case has no raw frames".into()); + } + let mut transcript = Vec::with_capacity(actions.len() + 1); + for action in actions { + let bytes = rejection_frame(action)?; + let mut child = spawn_raw_endpoint(endpoint, profile)?; + let mut input = child + .stdin + .take() + .ok_or_else(|| "endpoint stdin was not piped".to_string())?; + input.write_all(&bytes).map_err(|error| error.to_string())?; + input.flush().map_err(|error| error.to_string())?; + drop(input); + let output = child + .wait_with_output() + .map_err(|error| error.to_string())?; + if output + .stdout + .windows(CANARY.len()) + .any(|value| value == CANARY.as_bytes()) + || output + .stderr + .windows(CANARY.len()) + .any(|value| value == CANARY.as_bytes()) + { + return Err("provider endpoint exposed the canary while rejecting a frame".into()); + } + transcript.push(event("rejected")); + } + transcript.push(event("closed")); + Ok(transcript) +} + +fn run_strict_adapter_rejections(endpoint: &Path, case: &Case) -> Result, String> { + let actions = case + .actions + .iter() + .filter(|action| action.get("kind").and_then(Value::as_str) == Some("raw_frame")) + .collect::>(); + if actions.is_empty() { + return Err("strict rejection case has no raw frames".into()); + } + let mut transcript = Vec::with_capacity(actions.len() + 1); + for action in actions { + let provider = external_provider( + endpoint, + vec!["--reject-init".into(), rejection_argument(action)?], + )?; + if provider + .convention_address("payments", "production", "TOKEN") + .is_ok() + { + return Err("external adapter accepted an invalid initialization frame".into()); + } + transcript.push(event("rejected")); + } + transcript.push(event("closed")); + Ok(transcript) +} + +fn spawn_raw_endpoint(endpoint: &Path, profile: Option<&EndpointProfile>) -> Result { + let mut command = Command::new(endpoint); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(profile) = profile { + command + .args(&profile.arguments) + .env_clear() + .envs(&profile.environment); + } + command.spawn().map_err(|error| error.to_string()) +} + +fn frame(value: &Value) -> Result, String> { + let payload = serde_json::to_vec(value).map_err(|error| error.to_string())?; + let mut frame = payload; + frame.push(b'\n'); + Ok(frame) +} + +fn write_fragmented(writer: &mut impl Write, bytes: &[u8], chunks: &[usize]) -> Result<(), String> { + let mut offset = 0; + for chunk in chunks { + if offset == bytes.len() { + break; + } + let end = offset.saturating_add(*chunk).min(bytes.len()); + writer + .write_all(&bytes[offset..end]) + .map_err(|error| error.to_string())?; + writer.flush().map_err(|error| error.to_string())?; + offset = end; + } + writer + .write_all(&bytes[offset..]) + .map_err(|error| error.to_string())?; + writer.flush().map_err(|error| error.to_string()) +} + +fn write_json_frame(writer: &mut impl Write, value: &Value) -> Result<(), String> { + writer + .write_all(&frame(value)?) + .map_err(|error| error.to_string())?; + writer.flush().map_err(|error| error.to_string()) +} + +fn read_json_frame(reader: &mut impl Read) -> Result { + let mut payload = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + reader + .read_exact(&mut byte) + .map_err(|error| error.to_string())?; + if byte[0] == b'\n' { + break; + } + if byte[0] == b'\r' || payload.len() >= MAX_FRAME_BYTES { + return Err("endpoint returned an invalid frame".into()); + } + payload.push(byte[0]); + } + if payload.is_empty() { + return Err("endpoint returned an empty frame".into()); + } + serde_json::from_slice(&payload).map_err(|error| error.to_string()) +} + +fn rejection_frame(action: &Value) -> Result, String> { + if action.get("prefix_hex").and_then(Value::as_str) == Some("0000") { + return Ok(b"{".to_vec()); + } + if action.get("declared_length").and_then(Value::as_u64) == Some(10) + && action.get("payload_hex").and_then(Value::as_str) == Some("7b7d") + { + return Ok(b"{}".to_vec()); + } + if action.get("payload_hex").and_then(Value::as_str) == Some("") { + return Ok(b"\n".to_vec()); + } + if action.get("payload_hex").and_then(Value::as_str) == Some("ff") { + return Ok(vec![0xff, b'\n']); + } + if let Some(payload) = action.get("payload_utf8").and_then(Value::as_str) { + let mut frame = payload.as_bytes().to_vec(); + frame.push(b'\n'); + return Ok(frame); + } + if let Some(length) = action.get("declared_length").and_then(Value::as_u64) { + return Ok(vec![ + b'x'; + usize::try_from(length).map_err(|_| { + "declared length exceeds usize".to_string() + })? + ]); + } + Err("unsupported strict rejection action".into()) +} + +fn rejection_argument(action: &Value) -> Result { + if action.get("prefix_hex").and_then(Value::as_str) == Some("0000") { + return Ok("truncated-header".into()); + } + if action.get("declared_length").and_then(Value::as_u64) == Some(10) + && action.get("payload_hex").and_then(Value::as_str) == Some("7b7d") + { + return Ok("truncated-payload".into()); + } + if action.get("payload_hex").and_then(Value::as_str) == Some("") { + return Ok("empty".into()); + } + if action.get("payload_hex").and_then(Value::as_str) == Some("ff") { + return Ok("invalid-utf8".into()); + } + match action.get("payload_utf8").and_then(Value::as_str) { + Some("[]") => return Ok("batch".into()), + Some(r#"{"jsonrpc":"2.0","jsonrpc":"2.0"}"#) => return Ok("duplicate-key".into()), + Some(r#"{"jsonrpc":"2.0","id":999,"result":{}}"#) => return Ok("unknown-id".into()), + Some(_) => return Err("unsupported malformed JSON response".into()), + None => {} + } + if let Some(length) = action.get("declared_length").and_then(Value::as_u64) { + return Ok(format!( + "oversized:{}", + u32::try_from(length).map_err(|_| "declared length exceeds u32".to_string())? + )); + } + Err("unsupported strict rejection action".into()) +} diff --git a/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs b/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs new file mode 100644 index 000000000..f0a2ae1a5 --- /dev/null +++ b/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs @@ -0,0 +1,561 @@ +use async_trait::async_trait; +use secretspec_ipc::error::{ErrorKind, RpcError}; +use secretspec_ipc::protocol::callback::CredentialParams; +use secretspec_ipc::protocol::provider::{ + self as wire, Address, ClearParams, ClearScope, GetManyParams, GetManyResult, + InitializeApplication, Metadata, NamedGetResult, Persistence, ReflectParams, ReflectResult, + ResolveAddressResult, +}; +use secretspec_ipc::provider::{ + ProvidedSecret, ProviderHandler, SecretValue, request_credential, serve_provider, +}; +use secretspec_ipc::server::{RequestContext, RpcResult, ServerConfig}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MAX_FRAME_BYTES: usize = 1024 * 1024; + +enum Mode { + Serve { crash_on_get_once: Option }, + FragmentInitialize(Vec), + RejectInitialize(Rejection), +} + +enum Rejection { + Empty, + Batch, + DuplicateKey, + InvalidUtf8, + TruncatedHeader, + TruncatedPayload, + UnknownId, + Oversized(u32), +} + +struct StoredValue { + value: String, + storage_expires_at: Option, + secret_expires_at_unix_ms: Option, +} + +#[derive(Default)] +struct MemoryProvider { + values: Mutex>, + errors_seen: Mutex>, + crash_on_get_once: Option, +} + +impl MemoryProvider { + fn key(address: &Address) -> String { + match address { + Address::Convention { + project, + profile, + key, + } => format!("convention/{project}/{profile}/{key}"), + Address::Native { coordinates } => format!( + "native/{}/{}/{}/{}/{}", + coordinates.item, + coordinates.field.as_deref().unwrap_or(""), + coordinates.vault.as_deref().unwrap_or(""), + coordinates.section.as_deref().unwrap_or(""), + coordinates.version.as_deref().unwrap_or("") + ), + } + } + + fn read(values: &mut HashMap, key: &str) -> Option<(String, Option)> { + let now_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|now| u64::try_from(now.as_millis()).ok()); + let expired = values.get(key).is_some_and(|entry| { + entry + .storage_expires_at + .is_some_and(|expires_at| expires_at <= Instant::now()) + || entry + .secret_expires_at_unix_ms + .is_some_and(|expires_at| now_unix_ms.is_some_and(|now| now >= expires_at)) + }); + if expired { + values.remove(key); + return None; + } + values + .get(key) + .map(|entry| (entry.value.clone(), entry.secret_expires_at_unix_ms)) + } + + fn maybe_crash(&self) { + let Some(marker) = &self.crash_on_get_once else { + return; + }; + if !marker.exists() && std::fs::write(marker, b"crashed").is_ok() { + std::process::exit(42); + } + } + + fn one_shot_error(&self, key: &str) -> Option { + let kind = if key.ends_with("/__INTERACTION_REQUIRED__") { + ErrorKind::InteractionRequired + } else if key.ends_with("/__PERMISSION_DENIED__") { + ErrorKind::PermissionDenied + } else if key.ends_with("/__UNAVAILABLE__") { + ErrorKind::Unavailable + } else if key.ends_with("/__CONFLICT__") { + ErrorKind::Conflict + } else { + return None; + }; + if self.errors_seen.lock().unwrap().insert(key.to_string()) { + Some(RpcError::new(kind)) + } else { + None + } + } +} + +#[async_trait] +impl ProviderHandler for MemoryProvider { + fn capabilities(&self) -> Vec { + wire::CAPABILITIES + .iter() + .map(|capability| (*capability).to_string()) + .collect() + } + + async fn initialize( + &self, + context: &RequestContext, + application: InitializeApplication, + ) -> RpcResult { + // Optional by design: clients predating brokerage and native provider + // authentication remain valid. The adapter conformance path advertises + // the callback and verifies this request reaches its broker. + let _credential = request_credential( + context, + CredentialParams { + name: "conformance_token".into(), + scope: application.uri.clone(), + required: false, + }, + ) + .await?; + Ok(Metadata { + name: application.scheme.clone(), + display_uri: format!("{}://conformance", application.scheme), + supported_coordinates: vec![ + wire::CoordinateName::Field, + wire::CoordinateName::Vault, + wire::CoordinateName::Section, + wire::CoordinateName::Version, + ], + generated_value_persistence: Persistence::Persist, + prompted_value_persistence: Persistence::Ephemeral, + storage_identity: format!("{}://conformance", application.scheme), + entry_container_identity: format!("{}://conformance", application.scheme), + physical_store_path: None, + }) + } + + async fn resolve_address( + &self, + _context: RequestContext, + address: Address, + ) -> RpcResult { + let coordinates = match address { + Address::Convention { + project, + profile, + key, + } => wire::Coordinates { + item: format!("{project}/{profile}/{key}"), + field: None, + vault: None, + section: None, + version: None, + }, + Address::Native { coordinates } => coordinates, + }; + Ok(ResolveAddressResult { coordinates }) + } + + async fn get( + &self, + context: RequestContext, + address: Address, + ) -> RpcResult> { + let key = Self::key(&address); + if key.ends_with("/__BLOCK__") { + context.cancellation.cancelled().await; + return Err(RpcError::new(ErrorKind::Cancelled)); + } + if let Some(error) = self.one_shot_error(&key) { + return Err(error); + } + self.maybe_crash(); + Ok(Self::read(&mut self.values.lock().unwrap(), &key) + .map(|(value, expires_at)| ProvidedSecret::new(value, expires_at))) + } + + async fn get_many( + &self, + _context: RequestContext, + params: GetManyParams, + ) -> RpcResult { + self.maybe_crash(); + let mut values = self.values.lock().unwrap(); + Ok(GetManyResult { + results: params + .requests + .into_iter() + .map(|request| NamedGetResult { + name: request.name, + outcome: match Self::read(&mut values, &Self::key(&request.address)) { + Some((value, expires_at_unix_ms)) => wire::GetResult::Found { + value, + expires_at_unix_ms, + }, + None => wire::GetResult::Missing, + }, + }) + .collect(), + }) + } + + async fn exists(&self, _context: RequestContext, address: Address) -> RpcResult { + self.maybe_crash(); + Ok(Self::read(&mut self.values.lock().unwrap(), &Self::key(&address)).is_some()) + } + + async fn set( + &self, + _context: RequestContext, + address: Address, + value: SecretValue, + ) -> RpcResult<()> { + if let Some(error) = self.one_shot_error(&Self::key(&address)) { + return Err(error); + } + let key = Self::key(&address); + let secret_expires_at_unix_ms = key + .ends_with("/SECRET_EXPIRY") + .then(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|now| u64::try_from(now.as_millis()).ok()) + .and_then(|now| now.checked_add(60_000)) + }) + .flatten(); + self.values.lock().unwrap().insert( + key, + StoredValue { + value: value.expose().to_string(), + storage_expires_at: None, + secret_expires_at_unix_ms, + }, + ); + Ok(()) + } + + async fn set_expiring( + &self, + _context: RequestContext, + address: Address, + value: SecretValue, + ttl_ms: u64, + ) -> RpcResult<()> { + self.values.lock().unwrap().insert( + Self::key(&address), + StoredValue { + value: value.expose().to_string(), + storage_expires_at: Some(Instant::now() + Duration::from_millis(ttl_ms)), + secret_expires_at_unix_ms: None, + }, + ); + Ok(()) + } + + async fn delete(&self, _context: RequestContext, address: Address) -> RpcResult { + Ok(self + .values + .lock() + .unwrap() + .remove(&Self::key(&address)) + .is_some()) + } + + async fn clear(&self, _context: RequestContext, params: ClearParams) -> RpcResult { + let mut values = self.values.lock().unwrap(); + values.retain(|_, value| { + value + .storage_expires_at + .is_none_or(|expiry| expiry > Instant::now()) + }); + let before = values.len(); + match params.scope { + ClearScope::All => values.clear(), + ClearScope::Convention { project, profile } => { + let prefix = format!("convention/{project}/{profile}/"); + values.retain(|key, _| !key.starts_with(&prefix)); + } + } + Ok(before - values.len()) + } + + async fn check_writable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Ok(()) + } + + async fn check_deletable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Ok(()) + } + + async fn describe_write_target( + &self, + _context: RequestContext, + address: Address, + ) -> RpcResult { + Ok(format!("memory {}", Self::key(&address))) + } + + async fn reflect( + &self, + _context: RequestContext, + _params: ReflectParams, + ) -> RpcResult { + Ok(ReflectResult { + schema_version: 1, + declarations: BTreeMap::from([( + "TOKEN".into(), + wire::ReflectedDeclaration { + description: "Conformance token".into(), + required: true, + reference: wire::Coordinates { + item: "token".into(), + field: None, + vault: None, + section: None, + version: None, + }, + }, + )]), + }) + } +} + +fn arguments() -> Result { + let mut arguments = std::env::args_os().skip(1); + match arguments.next() { + None => Ok(Mode::Serve { + crash_on_get_once: None, + }), + Some(argument) if argument == "--crash-on-get-once" => { + let marker = arguments.next().map(PathBuf::from).ok_or(())?; + if arguments.next().is_some() { + return Err(()); + } + Ok(Mode::Serve { + crash_on_get_once: Some(marker), + }) + } + Some(argument) if argument == "--fragment-init" => { + let chunks = arguments + .next() + .and_then(|value| value.into_string().ok()) + .ok_or(())? + .split(',') + .map(|value| value.parse::().map_err(|_| ())) + .collect::, _>>()?; + if chunks.is_empty() || chunks.contains(&0) || arguments.next().is_some() { + return Err(()); + } + Ok(Mode::FragmentInitialize(chunks)) + } + Some(argument) if argument == "--reject-init" => { + let rejection = match arguments + .next() + .and_then(|value| value.into_string().ok()) + .as_deref() + { + Some("empty") => Rejection::Empty, + Some("batch") => Rejection::Batch, + Some("duplicate-key") => Rejection::DuplicateKey, + Some("invalid-utf8") => Rejection::InvalidUtf8, + Some("truncated-header") => Rejection::TruncatedHeader, + Some("truncated-payload") => Rejection::TruncatedPayload, + Some("unknown-id") => Rejection::UnknownId, + Some(value) if value.starts_with("oversized:") => Rejection::Oversized( + value + .strip_prefix("oversized:") + .ok_or(())? + .parse() + .map_err(|_| ())?, + ), + _ => return Err(()), + }; + if arguments.next().is_some() { + return Err(()); + } + Ok(Mode::RejectInitialize(rejection)) + } + Some(_) => Err(()), + } +} + +#[tokio::main] +async fn main() { + let Ok(mode) = arguments() else { + std::process::exit(2); + }; + let Mode::Serve { crash_on_get_once } = mode else { + if manual_peer(mode).is_err() { + std::process::exit(1); + } + return; + }; + let handler = MemoryProvider { + values: Mutex::new(HashMap::new()), + errors_seen: Mutex::new(HashSet::new()), + crash_on_get_once, + }; + if serve_provider( + tokio::io::stdin(), + tokio::io::stdout(), + handler, + ServerConfig::default(), + ) + .await + .is_err() + { + std::process::exit(1); + } +} + +fn manual_peer(mode: Mode) -> Result<(), ()> { + let mut input = io::stdin().lock(); + let mut output = io::stdout().lock(); + let request = read_json_frame(&mut input)?; + if request.get("method").and_then(Value::as_str) != Some("rpc.initialize") { + return Err(()); + } + let id = request.get("id").and_then(Value::as_u64).ok_or(())?; + match mode { + Mode::FragmentInitialize(chunks) => { + write_fragmented(&mut output, &initialize_response(id), &chunks)?; + let shutdown = read_json_frame(&mut input)?; + if shutdown.get("method").and_then(Value::as_str) != Some("rpc.shutdown") { + return Err(()); + } + let shutdown_id = shutdown.get("id").and_then(Value::as_u64).ok_or(())?; + write_json_frame( + &mut output, + &json!({"jsonrpc": "2.0", "id": shutdown_id, "result": {}}), + ) + } + Mode::RejectInitialize(rejection) => write_rejection(&mut output, rejection), + Mode::Serve { .. } => Err(()), + } +} + +fn initialize_response(id: u64) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocol": "secretspec.provider", + "version": 1, + "server": {"name": "provider-conformance-peer", "version": "1"}, + "methods": wire::CAPABILITIES, + "capabilities": {}, + "limits": {"max_frame_bytes": 32768, "max_in_flight": 8}, + "application": { + "provider": { + "name": "memory", + "display_uri": "memory://conformance", + "supported_coordinates": ["field", "vault", "section", "version"], + "generated_value_persistence": "persist", + "prompted_value_persistence": "ephemeral", + "storage_identity": "memory://conformance", + "entry_container_identity": "memory://conformance", + "physical_store_path": null + } + } + } + }) +} + +fn read_json_frame(reader: &mut impl Read) -> Result { + let mut payload = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + reader.read_exact(&mut byte).map_err(|_| ())?; + if byte[0] == b'\n' { + break; + } + if byte[0] == b'\r' || payload.len() >= MAX_FRAME_BYTES { + return Err(()); + } + payload.push(byte[0]); + } + if payload.is_empty() { + return Err(()); + } + serde_json::from_slice(&payload).map_err(|_| ()) +} + +fn write_json_frame(writer: &mut impl Write, value: &Value) -> Result<(), ()> { + let payload = serde_json::to_vec(value).map_err(|_| ())?; + write_raw_frame(writer, &payload) +} + +fn write_fragmented(writer: &mut impl Write, value: &Value, chunks: &[usize]) -> Result<(), ()> { + let payload = serde_json::to_vec(value).map_err(|_| ())?; + let mut frame = payload; + frame.push(b'\n'); + let mut offset = 0; + for chunk in chunks { + if offset == frame.len() { + break; + } + let end = offset.saturating_add(*chunk).min(frame.len()); + writer.write_all(&frame[offset..end]).map_err(|_| ())?; + writer.flush().map_err(|_| ())?; + offset = end; + } + writer.write_all(&frame[offset..]).map_err(|_| ())?; + writer.flush().map_err(|_| ()) +} + +fn write_rejection(writer: &mut impl Write, rejection: Rejection) -> Result<(), ()> { + match rejection { + Rejection::Empty => writer.write_all(b"\n").map_err(|_| ())?, + Rejection::Batch => write_raw_frame(writer, b"[]")?, + Rejection::DuplicateKey => { + write_raw_frame(writer, br#"{"jsonrpc":"2.0","jsonrpc":"2.0"}"#)? + } + Rejection::InvalidUtf8 => write_raw_frame(writer, &[0xff])?, + Rejection::TruncatedHeader => writer.write_all(b"{").map_err(|_| ())?, + Rejection::TruncatedPayload => { + writer.write_all(b"{}").map_err(|_| ())?; + } + Rejection::UnknownId => { + write_json_frame(writer, &json!({"jsonrpc": "2.0", "id": 999, "result": {}}))? + } + Rejection::Oversized(length) => writer + .write_all(&vec![b'x'; length as usize]) + .map_err(|_| ())?, + } + writer.flush().map_err(|_| ()) +} + +fn write_raw_frame(writer: &mut impl Write, payload: &[u8]) -> Result<(), ()> { + writer.write_all(payload).map_err(|_| ())?; + writer.write_all(b"\n").map_err(|_| ())?; + writer.flush().map_err(|_| ()) +} diff --git a/conformance/ipc/runner/src/main.rs b/conformance/ipc/runner/src/main.rs new file mode 100644 index 000000000..d621c3b95 --- /dev/null +++ b/conformance/ipc/runner/src/main.rs @@ -0,0 +1,300 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const CANARY: &str = "SECRETSPEC_IPC_CANARY_DO_NOT_LOG"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + schema_version: u32, + id: String, + targets: Vec, + timeout_ms: u64, + actions: Vec, + required_events: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Transcript { + case: String, + #[serde(default)] + status: TranscriptStatus, + #[serde(default)] + reason: Option, + events: Vec, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TranscriptStatus { + #[default] + Passed, + NotApplicable, +} + +fn main() { + if let Err(error) = execute() { + eprintln!("conformance: {error}"); + std::process::exit(1); + } +} + +fn execute() -> Result<(), String> { + let mut arguments = std::env::args_os().skip(1); + let action = arguments + .next() + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| "check".to_string()); + let cases = load_cases(&case_root())?; + match action.as_str() { + "check" => { + check_schema_assets()?; + println!("validated {} IPC conformance cases", cases.len()); + Ok(()) + } + "run" => { + let target = arguments + .next() + .and_then(|value| value.into_string().ok()) + .ok_or_else(|| "run requires a target name".to_string())?; + let command = arguments + .next() + .ok_or_else(|| "run requires a driver executable".to_string())?; + let command_arguments = arguments.collect::>(); + let selected = cases + .iter() + .filter(|case| { + case.targets + .iter() + .any(|candidate| candidate == "common" || candidate == &target) + }) + .collect::>(); + if selected.is_empty() { + return Err(format!("no cases select target {target}")); + } + for case in selected { + run_case(case, &command, &command_arguments)?; + } + Ok(()) + } + _ => Err("usage: secretspec-ipc-conformance [check | run TARGET COMMAND [ARGS...]]".into()), + } +} + +fn case_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../cases") +} + +fn schema_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../schema/ipc/v1") +} + +fn load_cases(directory: &Path) -> Result, String> { + let mut paths = std::fs::read_dir(directory) + .map_err(|error| error.to_string())? + .map(|entry| { + entry + .map(|entry| entry.path()) + .map_err(|error| error.to_string()) + }) + .collect::, _>>()?; + paths.sort(); + let mut cases = Vec::new(); + let mut ids = BTreeSet::new(); + for path in paths { + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let case: Case = serde_json::from_slice(&bytes) + .map_err(|error| format!("{}: {error}", path.display()))?; + validate_case(&case)?; + if !ids.insert(case.id.clone()) { + return Err(format!("duplicate case ID {}", case.id)); + } + cases.push(case); + } + if cases.is_empty() { + return Err("the conformance case set is empty".into()); + } + Ok(cases) +} + +fn validate_case(case: &Case) -> Result<(), String> { + if case.schema_version != 1 + || case.id.is_empty() + || case.targets.is_empty() + || case.timeout_ms == 0 + || case.timeout_ms > 60_000 + || case.actions.is_empty() + || case.required_events.is_empty() + { + return Err(format!("case {} violates the version 1 bounds", case.id)); + } + if case.targets.iter().collect::>().len() != case.targets.len() + || case.required_events.iter().collect::>().len() != case.required_events.len() + { + return Err(format!("case {} contains duplicates", case.id)); + } + Ok(()) +} + +fn check_schema_assets() -> Result<(), String> { + for name in [ + "common.schema.json", + "resolver.schema.json", + "provider.schema.json", + "resolver.openrpc.json", + "provider.openrpc.json", + ] { + let path = schema_root().join(name); + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("{}: {error}", path.display()))?; + if !value.is_object() { + return Err(format!("{} is not a JSON object", path.display())); + } + } + Ok(()) +} + +fn run_case( + case: &Case, + executable: &std::ffi::OsStr, + arguments: &[std::ffi::OsString], +) -> Result<(), String> { + let mut child = Command::new(executable) + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("{}: launch failed: {error}", case.id))?; + let mut input = serde_json::to_vec(case).map_err(|error| error.to_string())?; + input.push(b'\n'); + child + .stdin + .take() + .ok_or_else(|| "driver stdin was not piped".to_string())? + .write_all(&input) + .map_err(|error| error.to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "driver stdout was not piped".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "driver stderr was not piped".to_string())?; + let stdout_reader = std::thread::spawn(move || read_bounded(stdout)); + let stderr_reader = std::thread::spawn(move || read_bounded(stderr)); + let deadline = Instant::now() + Duration::from_millis(case.timeout_ms); + let status = loop { + if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("{}: driver timed out", case.id)); + } + std::thread::sleep(Duration::from_millis(5)); + }; + let stdout = stdout_reader + .join() + .map_err(|_| "driver stdout reader panicked".to_string())??; + let stderr = stderr_reader + .join() + .map_err(|_| "driver stderr reader panicked".to_string())??; + if contains_canary(&stdout) || contains_canary(&stderr) { + return Err(format!("{}: canary appeared in driver output", case.id)); + } + if !status.success() { + // Report what the driver said, not just that it died. The canary check + // above already ran, so this cannot echo a secret into a CI log. + let detail = String::from_utf8_lossy(&stderr); + let detail = detail.trim(); + return Err(if detail.is_empty() { + format!( + "{}: driver exited with {status} without writing stderr", + case.id + ) + } else { + format!("{}: driver exited with {status}: {detail}", case.id) + }); + } + let transcript: Transcript = serde_json::from_slice(&stdout) + .map_err(|error| format!("{}: invalid transcript: {error}", case.id))?; + if transcript.case != case.id { + return Err(format!("{}: transcript case mismatch", case.id)); + } + if transcript.status == TranscriptStatus::NotApplicable { + let reason = transcript + .reason + .as_deref() + .filter(|reason| !reason.trim().is_empty()) + .ok_or_else(|| format!("{}: not-applicable transcript has no reason", case.id))?; + if !transcript.events.is_empty() { + return Err(format!( + "{}: not-applicable transcript contains events", + case.id + )); + } + println!("not applicable {}: {reason}", case.id); + return Ok(()); + } + if transcript.reason.is_some() { + return Err(format!("{}: passed transcript contains a reason", case.id)); + } + let event_kinds = transcript + .events + .iter() + .filter_map(|event| event.get("kind").and_then(Value::as_str)) + .collect::>(); + for required in &case.required_events { + if !event_kinds.contains(required.as_str()) { + return Err(format!("{}: missing event {required}", case.id)); + } + } + println!("ok {}", case.id); + Ok(()) +} + +fn read_bounded(mut reader: impl Read) -> Result, String> { + const RETAIN: usize = 1024 * 1024; + let mut retained = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if read == 0 { + return Ok(retained); + } + let available = RETAIN.saturating_sub(retained.len()); + retained.extend_from_slice(&buffer[..read.min(available)]); + } +} + +fn contains_canary(bytes: &[u8]) -> bool { + bytes + .windows(CANARY.len()) + .any(|window| window == CANARY.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_in_cases_and_schemas_validate() { + assert!(!load_cases(&case_root()).unwrap().is_empty()); + check_schema_assets().unwrap(); + } +} diff --git a/conformance/ipc/runner/tests/async_lifecycle.rs b/conformance/ipc/runner/tests/async_lifecycle.rs new file mode 100644 index 000000000..4c7b336dc --- /dev/null +++ b/conformance/ipc/runner/tests/async_lifecycle.rs @@ -0,0 +1,91 @@ +use secretspec_ipc::deadline_unix_ms_after; +use secretspec_ipc::lifecycle::{Environment, LaunchOptions, spawn}; +use secretspec_ipc::protocol::{ + InitializeParams, InitializeResult, Limits, PROTOCOL_VERSION, Product, RESOLVER_PROTOCOL, +}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::Path; +use std::time::Duration; + +fn peer() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_ipc-fake-peer-rust")) +} + +fn launch_options(arguments: &[&str], environment: BTreeMap) -> LaunchOptions { + LaunchOptions { + executable: peer().to_path_buf(), + arguments: arguments.iter().map(OsString::from).collect(), + environment: Environment::Inherit(environment), + allow_path_discovery: false, + max_stderr_bytes: 4096, + } +} + +fn initialize() -> InitializeParams { + InitializeParams { + protocol: RESOLVER_PROTOCOL.to_string(), + versions: vec![PROTOCOL_VERSION], + client: Product { + name: "async-lifecycle-test".to_string(), + version: "1".to_string(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + } +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn an_expired_startup_kills_and_reaps_the_child() { + let directory = tempfile::tempdir().unwrap(); + let pid_file = directory.path().join("child.pid"); + let mut environment = BTreeMap::new(); + environment.insert( + OsString::from("SECRETSPEC_TEST_PID_FILE"), + pid_file.as_os_str().to_os_string(), + ); + + let result = spawn::<_, Value>( + launch_options(&["--silent-init"], environment), + initialize(), + deadline_unix_ms_after(Duration::from_secs(1)), + ) + .await; + assert!( + matches!(result, Err(secretspec_ipc::Error::DeadlineExceeded)), + "silent initialization produced an unexpected result" + ); + + let pid = std::fs::read_to_string(&pid_file) + .expect("the child did not record its PID before the startup deadline") + .parse::() + .unwrap(); + assert!( + !Path::new("/proc").join(pid.to_string()).exists(), + "startup failure left child PID {pid} alive or unreaped" + ); +} + +#[tokio::test] +async fn an_immediate_exit_after_shutdown_keeps_the_response() { + for _ in 0..32 { + let (session, _): (_, InitializeResult) = spawn( + launch_options(&[], BTreeMap::new()), + initialize(), + deadline_unix_ms_after(Duration::from_secs(2)), + ) + .await + .unwrap(); + + session + .close(deadline_unix_ms_after(Duration::from_secs(2))) + .await + .unwrap(); + } +} diff --git a/conformance/ipc/runner/tests/client_blocking.rs b/conformance/ipc/runner/tests/client_blocking.rs new file mode 100644 index 000000000..164ccf771 --- /dev/null +++ b/conformance/ipc/runner/tests/client_blocking.rs @@ -0,0 +1,296 @@ +//! Behavior of the synchronous `secretspec.resolver/1` client. +//! +//! The blocking client speaks the same wire protocol as the async one, so it is +//! exercised against the same fake peer: only the transport differs. + +use secretspec_ipc::blocking::ResolverSession; +use secretspec_ipc::error::{Error, ErrorKind}; +use secretspec_ipc::launch::{Environment, LaunchOptions}; +use secretspec_ipc::protocol::resolver::{ + GetParams, GetResult, InitializeApplication, Manifest, Purpose, ReleaseParams, Representation, +}; +use secretspec_ipc::protocol::{Limits, Product}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +fn deadline_after(duration: Duration) -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + now + duration.as_millis() as u64 +} + +fn peer() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_ipc-fake-peer-rust")) +} + +fn launch_options(arguments: &[&str]) -> LaunchOptions { + LaunchOptions { + executable: peer().to_path_buf(), + arguments: arguments.iter().map(OsString::from).collect(), + environment: Environment::Replace(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 4096, + } +} + +fn base_dir() -> PathBuf { + // The protocol requires an absolute, lexically normalized base directory. + std::env::current_dir() + .unwrap() + .canonicalize() + .unwrap() + .to_path_buf() +} + +fn application() -> InitializeApplication { + InitializeApplication { + manifest: Manifest::Inline { + toml: "[project]\nname = \"conformance\"\nrevision = \"1.0\"\n".into(), + base_dir: base_dir().to_string_lossy().into_owned(), + }, + provider: None, + profile: None, + scope: None, + reason: None, + requested_authorization_duration_ms: None, + } +} + +fn resolve_params(name: &str) -> GetParams { + GetParams { + name: name.into(), + representation: Representation::Value, + purpose: Purpose { + consumer: "conformance".into(), + operation: "test".into(), + host: None, + path: None, + }, + } +} + +fn launch(options: LaunchOptions) -> secretspec_ipc::Result { + ResolverSession::launch( + options, + Product { + name: "blocking-test".into(), + version: "1".into(), + }, + Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + application(), + deadline_after(Duration::from_secs(5)), + ) +} + +fn session(arguments: &[&str]) -> ResolverSession { + launch(launch_options(arguments)).expect("the peer completes initialization") +} + +/// `ResolverSession` is deliberately not `Debug`, so a failed launch is +/// unwrapped by hand rather than through `unwrap_err`. +fn launch_error(options: LaunchOptions) -> Error { + match launch(options) { + Ok(_) => panic!("initialization was expected to fail"), + Err(error) => error, + } +} + +#[test] +fn resolves_releases_and_shuts_down() { + let mut session = session(&[]); + assert!(session.capabilities().contains("resolver.get")); + assert!(session.initialized().supports_inline_manifest); + + let resolved = session + .get( + &resolve_params("RESOLVED_VALUE"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + match resolved { + GetResult::Value(value) => { + assert_eq!(value.value, "canary-value"); + assert_eq!(value.source_provider.as_deref(), Some("keyring://")); + } + other => panic!("expected an inline value, got {other:?}"), + } + + let released = session + .release( + &ReleaseParams { + path_lease_ids: vec!["lease-one".into()], + }, + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert_eq!(released.released, 1); + + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); + assert!(session.is_closed()); +} + +#[test] +fn unknown_notifications_do_not_interrupt_a_blocking_exchange() { + let mut session = session(&["--notify-init"]); + let resolved = session + .get( + &resolve_params("RESOLVED_VALUE"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert!(matches!(resolved, GetResult::Value(_))); + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn missing_and_undeclared_are_domain_results_rather_than_errors() { + let mut session = session(&[]); + let missing = session + .get( + &resolve_params("MISSING_REQUIRED"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert!(matches!(missing, GetResult::Missing(result) if result.required)); + + let undeclared = session + .get( + &resolve_params("UNDECLARED"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert!(matches!(undeclared, GetResult::Undeclared(_))); + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn remote_errors_keep_their_closed_kind() { + let mut session = session(&[]); + let error = session + .get( + &resolve_params("REFUSED"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap_err(); + assert_eq!(error.rpc_kind(), Some(ErrorKind::PermissionDenied)); + // A refusal is answered on the wire, so the session survives it. + assert!(!session.is_closed()); + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn a_silent_endpoint_does_not_hang_the_caller() { + let mut session = session(&[]); + // The peer accepts this name and never answers. A blocking read cannot be + // interrupted, so this only returns if the deadline kills the transport. + let error = session + .get( + &resolve_params("SILENT"), + deadline_after(Duration::from_millis(250)), + ) + .unwrap_err(); + assert!(matches!(error, Error::DeadlineExceeded)); + assert!(session.is_closed()); + // Closing an already-dead session still reaps the child rather than failing. + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn an_inherited_stdout_pipe_does_not_extend_the_deadline() { + let mut session = session(&["--descendant-holds-pipes"]); + let started = Instant::now(); + let error = session + .get( + &resolve_params("SILENT"), + deadline_after(Duration::from_millis(250)), + ) + .unwrap_err(); + assert!(matches!(error, Error::DeadlineExceeded)); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(session.is_closed()); +} + +#[test] +fn an_expired_deadline_is_rejected_before_sending() { + let mut session = session(&[]); + let error = session + .get(&resolve_params("RESOLVED_VALUE"), 1) + .unwrap_err(); + assert!(matches!(error, Error::DeadlineExceeded)); + // Nothing was written, so the session is still usable. + assert!(!session.is_closed()); + let resolved = session + .get( + &resolve_params("RESOLVED_VALUE"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert!(matches!(resolved, GetResult::Value(_))); + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn a_fragmented_initialization_is_reassembled() { + // One byte at a time exercises the incremental decoder across both the + // length prefix and the payload. + let mut session = session(&["--fragment-init", "1,1,1,1,1,1,1,1,1"]); + let resolved = session + .get( + &resolve_params("RESOLVED_VALUE"), + deadline_after(Duration::from_secs(5)), + ) + .unwrap(); + assert!(matches!(resolved, GetResult::Value(_))); + session + .close(deadline_after(Duration::from_secs(5))) + .unwrap(); +} + +#[test] +fn malformed_initialization_frames_are_rejected() { + for rejection in [ + "empty", + "batch", + "duplicate-key", + "invalid-utf8", + "truncated-header", + "truncated-payload", + "unknown-id", + "oversized:1048577", + ] { + let error = launch_error(launch_options(&["--reject-init", rejection])); + assert!( + matches!( + error, + Error::Protocol(_) | Error::ProtocolOwned(_) | Error::Closed + ), + "{rejection} produced {error:?}" + ); + } +} + +#[test] +fn a_relative_executable_requires_explicit_discovery() { + let mut options = launch_options(&[]); + options.executable = PathBuf::from("ipc-fake-peer-rust"); + assert!(matches!(launch_error(options), Error::Protocol(_))); +} diff --git a/conformance/ipc/runner/tests/client_cases.rs b/conformance/ipc/runner/tests/client_cases.rs new file mode 100644 index 000000000..533b1c5c6 --- /dev/null +++ b/conformance/ipc/runner/tests/client_cases.rs @@ -0,0 +1,47 @@ +use std::collections::BTreeSet; +use std::process::Command; + +fn run_client_cases(target: &str, implementation: &str) { + let output = Command::new(env!("CARGO_BIN_EXE_secretspec-ipc-conformance")) + .args([ + "run", + target, + env!("CARGO_BIN_EXE_ipc-client-conformance-driver"), + "--implementation", + implementation, + "--peer", + env!("CARGO_BIN_EXE_ipc-fake-peer-rust"), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{implementation} driver failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(output.stderr.is_empty(), "driver wrote to stderr"); + let completed = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(str::to_string) + .collect::>(); + assert_eq!( + completed, + BTreeSet::from([ + "ok client.lifecycle".to_string(), + "ok wire.fragmented-frame".to_string(), + "ok wire.strict-rejections".to_string(), + ]) + ); +} + +#[test] +fn checked_in_client_cases_run_against_the_c_client() { + run_client_cases("c-client", "c"); +} + +#[test] +fn checked_in_client_cases_run_against_the_rust_client() { + run_client_cases("rust-client", "rust"); +} diff --git a/conformance/ipc/runner/tests/client_differential.proptest-regressions b/conformance/ipc/runner/tests/client_differential.proptest-regressions new file mode 100644 index 000000000..ff880d0c5 --- /dev/null +++ b/conformance/ipc/runner/tests/client_differential.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 9a462816f3d7a8c1cca89bb8350f4310a0617ab419ed6b158c26f1230d3dbb31 # shrinks to history = [Echo { token: 204 }, Deadline] diff --git a/conformance/ipc/runner/tests/client_differential.rs b/conformance/ipc/runner/tests/client_differential.rs new file mode 100644 index 000000000..fcdcd588e --- /dev/null +++ b/conformance/ipc/runner/tests/client_differential.rs @@ -0,0 +1,510 @@ +use proptest::prelude::*; +use secretspec_ipc::error::Error; +use secretspec_ipc::lifecycle::{self, Environment, LaunchOptions}; +use secretspec_ipc::protocol::{InitializeParams, Limits, Product}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::ffi::{c_uchar, c_void}; +use std::path::Path; +use std::ptr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const ABI_VERSION: u32 = 1 << 16; +const STATUS_OK: i32 = 0; +const STATUS_CANCELLED: i32 = 6; +const STATUS_DEADLINE_EXCEEDED: i32 = 7; + +/// An absolute deadline far enough in the past that both clients reject the +/// call before writing anything. Using a real elapsed timeout instead would +/// make the outcome depend on how fast this machine round-trips to a child +/// process, which is the one thing a differential comparison must not vary on. +const EXPIRED_DEADLINE_UNIX_MS: u64 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum Action { + Echo { token: u8 }, + Cancel, + Deadline, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +enum Outcome { + Echo(u8), + Cancelled, + DeadlineExceeded, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Slice { + data: *const c_uchar, + size: usize, +} + +#[repr(C)] +struct Options { + struct_size: u32, + abi_version: u32, + flags: u32, + reserved: u32, + executable: Slice, + arguments: *const Slice, + argument_count: usize, + environment: *const Slice, + environment_count: usize, + initialize_params_json: Slice, + max_stderr_bytes: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Buffer { + data: *mut c_uchar, + size: usize, +} + +unsafe extern "C" { + fn secretspec_resolver_abi_version() -> u32; + fn secretspec_resolver_client_open( + options: *const Options, + deadline_unix_ms: u64, + client: *mut *mut c_void, + server_info: *mut Buffer, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_start( + client: *mut c_void, + method: *const c_uchar, + method_size: usize, + params_json: *const c_uchar, + params_size: usize, + deadline_unix_ms: u64, + call: *mut *mut c_void, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_client_call( + client: *mut c_void, + method: *const c_uchar, + method_size: usize, + params_json: *const c_uchar, + params_size: usize, + deadline_unix_ms: u64, + result: *mut Buffer, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_wait( + call: *mut c_void, + result: *mut Buffer, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_call_cancel(call: *mut c_void); + fn secretspec_resolver_call_free(call: *mut c_void); + fn secretspec_resolver_client_close( + client: *mut c_void, + deadline_unix_ms: u64, + error: *mut Buffer, + ) -> i32; + fn secretspec_resolver_client_free(client: *mut c_void); + fn secretspec_resolver_buffer_free(buffer: Buffer); +} + +struct CClient(*mut c_void); + +impl CClient { + fn open(executable: &Path) -> Result { + let executable = executable.to_string_lossy().into_owned().into_bytes(); + let initialize = + serde_json::to_vec(&initialize_params()).map_err(|error| error.to_string())?; + let options = Options { + struct_size: u32::try_from(std::mem::size_of::()).unwrap(), + abi_version: ABI_VERSION, + flags: 0, + reserved: 0, + executable: slice(&executable), + arguments: ptr::null(), + argument_count: 0, + environment: ptr::null(), + environment_count: 0, + initialize_params_json: slice(&initialize), + max_stderr_bytes: 4096, + }; + let mut client = ptr::null_mut(); + let mut server = empty_buffer(); + let mut error = empty_buffer(); + // SAFETY: all slices remain alive for the duration of the call and all + // output pointers refer to initialized writable storage. + let status = unsafe { + secretspec_resolver_client_open( + &options, + deadline_after(Duration::from_secs(2)), + &mut client, + &mut server, + &mut error, + ) + }; + free_buffer(server); + if status == STATUS_OK && !client.is_null() { + free_buffer(error); + Ok(Self(client)) + } else { + Err(take_error(error, status)) + } + } + + fn execute(&self, action: &Action) -> Result { + let (params, deadline_unix_ms, cancel) = match action { + Action::Echo { token } => ( + json!({ + "mode": "echo", + "token": token + }), + deadline_after(Duration::from_secs(2)), + false, + ), + Action::Cancel => ( + json!({ + "mode": "pending" + }), + deadline_after(Duration::from_secs(2)), + true, + ), + Action::Deadline => ( + json!({ + "mode": "pending" + }), + EXPIRED_DEADLINE_UNIX_MS, + false, + ), + }; + let params = serde_json::to_vec(¶ms).map_err(|error| error.to_string())?; + let method = b"resolver.get"; + if matches!(action, Action::Echo { .. }) { + let mut result = empty_buffer(); + let mut error = empty_buffer(); + // SAFETY: the client and input slices are live and both outputs are writable. + let status = unsafe { + secretspec_resolver_client_call( + self.0, + method.as_ptr(), + method.len(), + params.as_ptr(), + params.len(), + deadline_unix_ms, + &mut result, + &mut error, + ) + }; + if status != STATUS_OK { + free_buffer(result); + return Err(take_error(error, status)); + } + let value = copy_buffer(result)?; + free_buffer(error); + let value: Value = serde_json::from_slice(&value).map_err(|error| error.to_string())?; + let token = value + .get("echo") + .and_then(Value::as_u64) + .ok_or("missing echo")?; + return Ok(Outcome::Echo( + u8::try_from(token).map_err(|_| "invalid echo")?, + )); + } + let mut call = ptr::null_mut(); + let mut error = empty_buffer(); + // SAFETY: the client is live, byte slices remain valid for this call, + // and the output pointers refer to writable storage. + let start_status = unsafe { + secretspec_resolver_call_start( + self.0, + method.as_ptr(), + method.len(), + params.as_ptr(), + params.len(), + deadline_unix_ms, + &mut call, + &mut error, + ) + }; + if start_status == STATUS_DEADLINE_EXCEEDED { + free_buffer(error); + return Ok(Outcome::DeadlineExceeded); + } + if start_status != STATUS_OK || call.is_null() { + return Err(take_error(error, start_status)); + } + free_buffer(error); + if cancel { + // SAFETY: `call` remains owned until the matching free below. + unsafe { secretspec_resolver_call_cancel(call) }; + } + let mut result = empty_buffer(); + let mut error = empty_buffer(); + // SAFETY: exactly one waiter uses this live call handle. + let status = unsafe { secretspec_resolver_call_wait(call, &mut result, &mut error) }; + // SAFETY: waiting has completed and no other thread uses the handle. + unsafe { secretspec_resolver_call_free(call) }; + match status { + STATUS_OK => { + let value = copy_buffer(result)?; + free_buffer(error); + let value: Value = + serde_json::from_slice(&value).map_err(|error| error.to_string())?; + let token = value + .get("echo") + .and_then(Value::as_u64) + .ok_or("missing echo")?; + Ok(Outcome::Echo( + u8::try_from(token).map_err(|_| "invalid echo")?, + )) + } + STATUS_CANCELLED => { + free_buffer(result); + free_buffer(error); + Ok(Outcome::Cancelled) + } + STATUS_DEADLINE_EXCEEDED => { + free_buffer(result); + free_buffer(error); + Ok(Outcome::DeadlineExceeded) + } + other => { + free_buffer(result); + Err(take_error(error, other)) + } + } + } + + fn close(mut self) -> Result<(), String> { + let mut error = empty_buffer(); + // SAFETY: this is the sole close of the live client. + let status = unsafe { + secretspec_resolver_client_close( + self.0, + deadline_after(Duration::from_secs(2)), + &mut error, + ) + }; + // SAFETY: close has made every call terminal and joined the worker. + unsafe { secretspec_resolver_client_free(self.0) }; + self.0 = ptr::null_mut(); + if status == STATUS_OK { + free_buffer(error); + Ok(()) + } else { + Err(take_error(error, status)) + } + } +} + +impl Drop for CClient { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: emergency free accepts a live client and performs its + // bounded close; ownership is not used afterward. + unsafe { secretspec_resolver_client_free(self.0) }; + self.0 = ptr::null_mut(); + } + } +} + +fn run_c(executable: &Path, history: &[Action]) -> Result, String> { + // SAFETY: this function takes no pointers and reports the linked ABI value. + if unsafe { secretspec_resolver_abi_version() } != ABI_VERSION { + return Err("C ABI version mismatch".into()); + } + let client = CClient::open(executable)?; + let outcomes = history + .iter() + .map(|action| client.execute(action)) + .collect::, _>>()?; + client.close()?; + Ok(outcomes) +} + +fn run_rust(executable: &Path, history: &[Action]) -> Result, String> { + let runtime = tokio::runtime::Runtime::new().map_err(|error| error.to_string())?; + runtime.block_on(async { + let launch = LaunchOptions { + executable: executable.to_path_buf(), + arguments: Vec::new(), + environment: Environment::Replace(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 4096, + }; + let (session, _) = lifecycle::spawn::<_, Value>( + launch, + initialize_params(), + deadline_after(Duration::from_secs(2)), + ) + .await + .map_err(|error| error.stable_message().to_string())?; + let mut outcomes = Vec::with_capacity(history.len()); + for action in history { + let outcome = match action { + Action::Echo { token } => { + let deadline = deadline_after(Duration::from_secs(2)); + let value: Value = session + .client() + .call( + "resolver.get", + &json!({ + "mode": "echo", + "token": token + }), + deadline, + ) + .await + .map_err(|error| error.stable_message().to_string())?; + Outcome::Echo( + value + .get("echo") + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + .ok_or_else(|| "invalid Rust echo".to_string())?, + ) + } + Action::Cancel => { + let deadline = deadline_after(Duration::from_secs(2)); + let mut call = session + .client() + .start("resolver.get", &json!({"mode": "pending"}), deadline) + .await + .map_err(|error| error.stable_message().to_string())?; + call.cancel() + .await + .map_err(|error| error.stable_message().to_string())?; + match call.wait().await { + Err(Error::Cancelled) => Outcome::Cancelled, + other => return Err(format!("unexpected Rust cancellation: {other:?}")), + } + } + Action::Deadline => { + // An expired deadline is rejected before anything is + // written, so accept it at either point: what matters is + // that both clients report it and stay usable. + match session + .client() + .start( + "resolver.get", + &json!({"mode": "pending"}), + EXPIRED_DEADLINE_UNIX_MS, + ) + .await + { + Err(Error::DeadlineExceeded) => Outcome::DeadlineExceeded, + Err(other) => { + return Err(format!("unexpected Rust deadline: {other:?}")); + } + Ok(mut call) => match call.wait().await { + Err(Error::DeadlineExceeded) => Outcome::DeadlineExceeded, + other => return Err(format!("unexpected Rust deadline: {other:?}")), + }, + } + } + }; + outcomes.push(outcome); + } + session + .close(deadline_after(Duration::from_secs(2))) + .await + .map_err(|error| error.stable_message().to_string())?; + Ok(outcomes) + }) +} + +fn initialize_params() -> InitializeParams { + InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "differential-client".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32768, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + } +} + +fn deadline_after(duration: Duration) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .saturating_add(duration.as_millis()) + .min(u64::MAX as u128) as u64 +} + +fn slice(bytes: &[u8]) -> Slice { + Slice { + data: bytes.as_ptr(), + size: bytes.len(), + } +} + +const fn empty_buffer() -> Buffer { + Buffer { + data: ptr::null_mut(), + size: 0, + } +} + +fn copy_buffer(buffer: Buffer) -> Result, String> { + if buffer.data.is_null() && buffer.size != 0 { + return Err("C returned an invalid buffer".into()); + } + if buffer.size == 0 { + free_buffer(buffer); + return Ok(Vec::new()); + } + // SAFETY: successful C buffers are library-owned allocations valid for + // `size` bytes until `secretspec_resolver_buffer_free`. + let bytes = unsafe { std::slice::from_raw_parts(buffer.data, buffer.size) }.to_vec(); + free_buffer(buffer); + Ok(bytes) +} + +fn take_error(buffer: Buffer, status: i32) -> String { + let bytes = copy_buffer(buffer).unwrap_or_default(); + format!("C status {status}: {}", String::from_utf8_lossy(&bytes)) +} + +fn free_buffer(buffer: Buffer) { + // SAFETY: buffers are either `{NULL, 0}` or returned by the linked C + // library, and each is passed here at most once. + unsafe { secretspec_resolver_buffer_free(buffer) }; +} + +fn action_strategy() -> impl Strategy { + prop_oneof![ + any::().prop_map(|token| Action::Echo { token }), + Just(Action::Cancel), + Just(Action::Deadline), + ] +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 32, + max_shrink_iters: 4096, + ..ProptestConfig::default() + })] + + #[test] + fn c_and_rust_clients_produce_the_same_normalized_history( + history in prop::collection::vec(action_strategy(), 1..8), + ) { + let peer = Path::new(env!("CARGO_BIN_EXE_ipc-fake-peer-rust")); + let serialized = serde_json::to_vec(&history).unwrap(); + let replay: Vec = serde_json::from_slice(&serialized).unwrap(); + let c = run_c(peer, &replay).map_err(TestCaseError::fail)?; + let rust = run_rust(peer, &replay).map_err(TestCaseError::fail)?; + prop_assert_eq!(c, rust, "history: {}", String::from_utf8_lossy(&serialized)); + } +} diff --git a/conformance/ipc/runner/tests/frame_differential.rs b/conformance/ipc/runner/tests/frame_differential.rs new file mode 100644 index 000000000..2d4a8a52a --- /dev/null +++ b/conformance/ipc/runner/tests/frame_differential.rs @@ -0,0 +1,48 @@ +use proptest::prelude::*; +use secretspec_ipc::frame::FrameDecoder; + +fn reference_decode(bytes: &[u8], limit: usize) -> Result>, ()> { + let mut frames = Vec::new(); + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if !line.ends_with(b"\n") { + return Err(()); + } + let payload = line[..line.len() - 1].to_vec(); + if payload.is_empty() || payload.len() > limit || payload.contains(&b'\r') { + return Err(()); + } + std::str::from_utf8(&payload).map_err(|_| ())?; + frames.push(payload); + } + Ok(frames) +} + +proptest! { + #[test] + fn incremental_codec_matches_independent_reference( + payloads in prop::collection::vec("[ -~]{1,128}", 1..8), + chunks in prop::collection::vec(1usize..64, 1..64), + ) { + let mut bytes = Vec::new(); + for payload in &payloads { + bytes.extend_from_slice(payload.as_bytes()); + bytes.push(b'\n'); + } + let expected = reference_decode(&bytes, 4096).unwrap(); + let mut decoder = FrameDecoder::new(4096).unwrap(); + let mut actual = Vec::new(); + let mut cursor = 0; + for chunk in chunks { + let end = (cursor + chunk).min(bytes.len()); + actual.extend(decoder.push(&bytes[cursor..end]).unwrap()); + cursor = end; + if cursor == bytes.len() { break; } + } + if cursor != bytes.len() { + actual.extend(decoder.push(&bytes[cursor..]).unwrap()); + } + decoder.finish_eof().unwrap(); + let actual = actual.into_iter().map(|value| value.to_vec()).collect::>(); + prop_assert_eq!(actual, expected); + } +} diff --git a/conformance/ipc/runner/tests/provider_cases.rs b/conformance/ipc/runner/tests/provider_cases.rs new file mode 100644 index 000000000..36e8235ee --- /dev/null +++ b/conformance/ipc/runner/tests/provider_cases.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeSet; +use std::process::Command; + +fn run_provider_cases(target: &str, implementation: &str, expected: &[&str]) { + let output = Command::new(env!("CARGO_BIN_EXE_secretspec-ipc-conformance")) + .args([ + "run", + target, + env!("CARGO_BIN_EXE_ipc-provider-conformance-driver"), + "--implementation", + implementation, + "--endpoint", + env!("CARGO_BIN_EXE_ipc-provider-endpoint-rust"), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{implementation} provider suite failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(output.stderr.is_empty(), "provider suite wrote to stderr"); + let completed = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(str::to_string) + .collect::>(); + assert_eq!( + completed, + expected + .iter() + .map(|case| format!("ok {case}")) + .collect::>() + ); +} + +#[test] +fn checked_in_provider_cases_run_against_the_rust_endpoint() { + run_provider_cases( + "provider-endpoint", + "endpoint", + &[ + "provider.errors", + "provider.lifecycle", + "provider.operations", + "wire.fragmented-frame", + "wire.initialization-state", + "wire.lifecycle", + "wire.notifications", + "wire.strict-rejections", + ], + ); +} + +#[test] +fn checked_in_provider_cases_run_through_the_external_adapter() { + run_provider_cases( + "external-adapter", + "adapter", + &[ + "provider.errors", + "provider.operations", + "provider.reconnect", + "provider.session-isolation", + "wire.fragmented-frame", + "wire.strict-rejections", + ], + ); +} + +#[test] +fn transport_only_profile_runs_wire_cases_and_reports_semantic_cases() { + let profile = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../profiles/transport-only.example.json"); + let output = Command::new(env!("CARGO_BIN_EXE_secretspec-ipc-conformance")) + .args([ + "run", + "provider-endpoint", + env!("CARGO_BIN_EXE_ipc-provider-conformance-driver"), + "--implementation", + "endpoint", + "--endpoint", + env!("CARGO_BIN_EXE_ipc-provider-endpoint-rust"), + "--profile", + ]) + .arg(profile) + .output() + .unwrap(); + assert!( + output.status.success(), + "transport-only suite failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("ok wire.fragmented-frame")); + assert!(stdout.contains("ok wire.initialization-state")); + assert!(stdout.contains("ok wire.lifecycle")); + assert!(stdout.contains("ok wire.notifications")); + assert!(stdout.contains("ok wire.strict-rejections")); + assert!(stdout.contains("not applicable provider.operations:")); + assert!(stdout.contains("not applicable provider.lifecycle:")); + assert!(stdout.contains("not applicable provider.errors:")); +} diff --git a/conformance/run.sh b/conformance/run.sh index 628886d90..144763a56 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -2,7 +2,7 @@ # # Aggregate cross-language conformance runner. # -# Builds the secretspec-ffi cdylib once, then runs every SDK's conformance suite +# Builds the libsecretspec cdylib once, then runs every SDK's conformance suite # against the shared fixtures and reports a combined result. Run inside the # project devenv shell (which provides cargo, python, go, ruby, node, dotnet): # @@ -15,22 +15,22 @@ set -uo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" -echo "==> Building secretspec-ffi cdylib" -cargo build -p secretspec-ffi || exit 1 +echo "==> Building libsecretspec cdylib" +cargo build -p libsecretspec || exit 1 target_dir="$(cargo metadata --no-deps --format-version 1 \ | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" case "$(uname -s)" in - Darwin) lib_name="libsecretspec_ffi.dylib" ;; - MINGW*|MSYS*|CYGWIN*) lib_name="secretspec_ffi.dll" ;; - *) lib_name="libsecretspec_ffi.so" ;; + Darwin) lib_name="libsecretspec.dylib" ;; + MINGW*|MSYS*|CYGWIN*) lib_name="secretspec.dll" ;; + *) lib_name="libsecretspec.so" ;; esac export SECRETSPEC_FFI_LIB="$target_dir/debug/$lib_name" # Static-link contract (see scripts/ci-sdks.sh): the .a plus the archive's # transitive native deps, for SDKs that link statically instead of dlopening. -export SECRETSPEC_FFI_STATICLIB="$target_dir/debug/libsecretspec_ffi.a" -export SECRETSPEC_FFI_INCLUDE="$repo_root/secretspec-ffi/include" -SECRETSPEC_FFI_NATIVE_LIBS="$(cargo rustc -q -p secretspec-ffi --crate-type staticlib -- \ +export SECRETSPEC_FFI_STATICLIB="$target_dir/debug/libsecretspec.a" +export SECRETSPEC_FFI_INCLUDE="$repo_root/libsecretspec/include" +SECRETSPEC_FFI_NATIVE_LIBS="$(cargo rustc -q -p libsecretspec --crate-type staticlib -- \ --print native-static-libs 2>&1 | sed -n 's/^note: native-static-libs: //p' | tail -1)" export SECRETSPEC_FFI_NATIVE_LIBS echo "==> SECRETSPEC_FFI_LIB=$SECRETSPEC_FFI_LIB" @@ -64,7 +64,7 @@ run_node() { ( ); } run_haskell() { ( cd secretspec-hs - # The Haskell SDK statically links the secretspec-ffi archive at build time, so + # The Haskell SDK statically links the libsecretspec archive at build time, so # there is no runtime loader path. Stage the .a alone (target/debug also holds # the .so) and pass its transitive native deps as linker options. hs_lib_dir="$(mktemp -d)" diff --git a/devenv.lock b/devenv.lock index 9e27908bc..5f666703f 100644 --- a/devenv.lock +++ b/devenv.lock @@ -56,17 +56,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782847189, - "narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=", + "lastModified": 1787333880, + "narHash": "sha256-NXm7qYzRNNlta35BWCnmdhwLBXe+gzk3zxx3GsevUvk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b6018f87da91d19d0ab4cf979885689b469cdd41", + "rev": "2f3aa44ed8975f834c76d5ca91b11c42c3158097", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", "repo": "nixpkgs", + "rev": "2f3aa44ed8975f834c76d5ca91b11c42c3158097", "type": "github" } }, diff --git a/devenv.nix b/devenv.nix index 2a2fdcb33..caf5413cd 100644 --- a/devenv.nix +++ b/devenv.nix @@ -33,11 +33,11 @@ }; }; # Go SDK (secretspec-go): default binding is purego (dlopen, no cgo); the - # `-tags static` binding uses cgo to statically link libsecretspec_ffi.a, and on + # `-tags static` binding uses cgo to statically link libsecretspec.a, and on # Linux is built fully static against musl (see the env block below). languages.go.enable = true; # Ruby SDK (secretspec-rb) compiles an mkmf C extension that statically links - # libsecretspec_ffi.a. + # libsecretspec.a. languages.ruby.enable = true; # Haskell SDK (secretspec-hs) links the C ABI at build time via the FFI. # Supply its only non-boot dependency from Nix's binary cache. Otherwise a @@ -54,7 +54,7 @@ # PHP SDK (secretspec-php) has two native backends over the same resolver: # * secretspec-php-native, an ext-php-rs extension that embeds the resolver # (the production path: no ffi.enable, works in FPM like ext-redis); and - # * a runtime ext-ffi fallback that dlopens the secretspec-ffi cdylib. + # * a runtime ext-ffi fallback that dlopens the libsecretspec cdylib. # The pure-PHP client prefers the extension when loaded. ext-ffi (enabled here) # covers the fallback + dev; composer (bundled with languages.php) manages the # dev-only phpunit dependency. @@ -71,6 +71,8 @@ pkgs.lychee # coverage testing pkgs.cargo-tarpaulin + # coverage-guided fuzzing of IPC wire targets (requires a nightly toolchain) + pkgs.cargo-fuzz # installers pkgs.cargo-dist # bitwarden-cli for integration testing @@ -90,7 +92,14 @@ # For development of the SOPS provider pkgs.sops pkgs.pkg-config - # Installs the secretspec-ffi archive with its header and pkg-config file + # JSON parsing for libsecretspec-resolver, resolved through pkg-config + # (Meson, cc-rs) and its CMake package config rather than vendored + pkgs.yyjson + # Standalone libsecretspec-resolver builds and install metadata. + pkgs.cmake + pkgs.meson + pkgs.ninja + # Installs the libsecretspec archive with its header and pkg-config file pkgs.cargo-c ]; @@ -135,6 +144,28 @@ bash tests/cli-integration.sh ''; + # Keep production builds on the stable toolchain from rust-toolchain.toml; + # libFuzzer alone needs nightly's sanitizer coverage flags. Referencing + # rustup by its Nix path avoids placing its cargo shim ahead of the pinned + # compiler in ordinary development shells. + scripts.install-fuzz-nightly.exec = '' + ${pkgs.rustup}/bin/rustup toolchain install nightly + ''; + + scripts.fuzz-resolver.exec = '' + nightly_cargo="$(${pkgs.rustup}/bin/rustup which --toolchain nightly cargo)" + export PATH="$(dirname "$nightly_cargo"):$PATH" + export RUSTC="$(${pkgs.rustup}/bin/rustup which --toolchain nightly rustc)" + cargo fuzz run resolver_wire -- "$@" + ''; + + scripts.fuzz-resolve-apis.exec = '' + nightly_cargo="$(${pkgs.rustup}/bin/rustup which --toolchain nightly cargo)" + export PATH="$(dirname "$nightly_cargo"):$PATH" + export RUSTC="$(${pkgs.rustup}/bin/rustup which --toolchain nightly rustc)" + cargo fuzz run resolve_apis -- "$@" + ''; + processes.docs.exec = '' cd docs && npm run dev ''; diff --git a/devenv.yaml b/devenv.yaml index b92a16f4c..0f86ce974 100644 --- a/devenv.yaml +++ b/devenv.yaml @@ -4,8 +4,14 @@ inputs: inputs: nixpkgs: follows: nixpkgs + # Pinned to a nixpkgs master commit rather than nixos-25.11 because + # libsecretspec-resolver resolves yyjson through pkg-config and CMake instead + # of vendoring it. Both nixos-25.11 and nixos-unstable still ship a yyjson.pc + # whose libdir/includedir double apply the prefix, so discovery is broken + # there. Master carries the upstream fix (ibireme/yyjson#295). Move back to a + # release channel once that patch reaches one. nixpkgs: - url: github:NixOS/nixpkgs/nixos-25.11 + url: github:NixOS/nixpkgs/2f3aa44ed8975f834c76d5ca91b11c42c3158097 rust-overlay: url: github:oxalica/rust-overlay inputs: diff --git a/docs/astro.config.ts b/docs/astro.config.ts index b16114145..2a220bd3a 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -387,6 +387,10 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil slug: "integrations/git", badge: { text: "0.20+", variant: "note" }, }, + { + label: "Adding an Integration", + slug: "integrations/adding", + }, ], }, { @@ -456,6 +460,28 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil slug: "reference/provider-credentials", badge: { text: "0.15+", variant: "note" }, }, + { + label: "IPC protocols", + badge: { text: "0.20+", variant: "note" }, + items: [ + { + label: "Architecture", + slug: "reference/ipc-architecture", + }, + { + label: "Wire protocol", + slug: "reference/ipc-wire", + }, + { + label: "Resolution protocol", + slug: "reference/resolver-protocol", + }, + { + label: "Provider protocol", + slug: "reference/provider-protocol", + }, + ], + }, ], }, { @@ -469,6 +495,11 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil label: "Adding an SDK", slug: "development/sdks", }, + { + label: "Implementing IPC", + slug: "development/ipc-implementation", + badge: { text: "0.20+", variant: "note" }, + }, ], }, ], diff --git a/docs/src/content/docs/blog/secretspec-0-13-sdks.md b/docs/src/content/docs/blog/secretspec-0-13-sdks.md index 47f410ef4..11bbde763 100644 --- a/docs/src/content/docs/blog/secretspec-0-13-sdks.md +++ b/docs/src/content/docs/blog/secretspec-0-13-sdks.md @@ -18,6 +18,9 @@ Node.js / TypeScript, Go, Ruby, and Haskell. Each resolves the exact secrets you manifest declares, through the same providers, profiles, fallback chains, and generators as the CLI, with no per-language configuration. +> This article uses the original `secretspec-ffi` name. The embedded ABI is +> named `libsecretspec` in SecretSpec 0.20+. + ## Native bindings over one resolver Every SDK is a thin client over the same Rust core that powers the CLI. No diff --git a/docs/src/content/docs/blog/secretspec-0-17-scopes-secrets-caching-age-and-systemd-credentials.md b/docs/src/content/docs/blog/secretspec-0-17-scopes-secrets-caching-age-and-systemd-credentials.md index 8c999ae2a..474559a7a 100644 --- a/docs/src/content/docs/blog/secretspec-0-17-scopes-secrets-caching-age-and-systemd-credentials.md +++ b/docs/src/content/docs/blog/secretspec-0-17-scopes-secrets-caching-age-and-systemd-credentials.md @@ -61,7 +61,8 @@ authorization boundary when the child itself holds provider credentials. Carrying the selected scope through resolver requests and results required a breaking change to -[`secretspec-ffi`](https://github.com/cachix/secretspec/tree/main/secretspec-ffi). +[`secretspec-ffi`](https://github.com/cachix/secretspec/tree/main/libsecretspec), +which is named `libsecretspec` in SecretSpec 0.20+. All [SecretSpec SDKs](/sdk/overview/) have been updated for 0.17 to support scopes, so applications should upgrade their SDK package and bundled native resolver together. diff --git a/docs/src/content/docs/blog/secretspec-0-19-moving-and-importing-secrets-between-providers.md b/docs/src/content/docs/blog/secretspec-0-19-moving-and-importing-secrets-between-providers.md index 245374642..69c966451 100644 --- a/docs/src/content/docs/blog/secretspec-0-19-moving-and-importing-secrets-between-providers.md +++ b/docs/src/content/docs/blog/secretspec-0-19-moving-and-importing-secrets-between-providers.md @@ -363,6 +363,10 @@ the same manifest can continue to inherit the default profile. ### pkg-config metadata for secretspec-ffi +> This section describes the 0.19 names. In SecretSpec 0.20+, the component is +> named `libsecretspec`, installed with `cargo cinstall -p libsecretspec`, and +> provides `libsecretspec.pc`. + `cargo cinstall -p secretspec-ffi` now installs the library, C header, and a `secretspec_ffi.pc` file containing the complete link metadata. Go builds can use the `pkgconfig` tag, Ruby native extensions accept `--enable-pkg-config`, diff --git a/docs/src/content/docs/concepts/audit.md b/docs/src/content/docs/concepts/audit.md index f8095fde7..cd3ed23e8 100644 --- a/docs/src/content/docs/concepts/audit.md +++ b/docs/src/content/docs/concepts/audit.md @@ -52,6 +52,7 @@ and how to turn it off. "operation": "credential_get", "resource": "github.com" }, + "purpose": { "consumer": "python-sdk", "operation": "resolve" }, "actor": { "user": "alice", "agent": "claude-code", "is_agent": true }, "version": "0.20.0" } @@ -74,8 +75,10 @@ and how to turn it off. | `outcome` | `found`, `missing`, `default`, `written`, `deleted` (0.17+ cache clear), `started` (a `run` launched its command), or `error` | | | A cached route writing its local entry is recorded as `cache_refresh`/`written`, never as `set`: no authoritative store was written. Dropping an entry — `cache clear`, or an entry a write superseded — is `cache_clear`/`deleted`. | | `error_kind` | A non-sensitive tag when `outcome` is `error` | +| `interaction` | Opaque provider interaction kind, ID, and optional expiry for an actionable failure (SecretSpec 0.20+); correlation only, never authorization material | | `reason` | The reason supplied via `--reason` / `SECRETSPEC_REASON` / the SDK, if any | | `caller` | Caller-asserted software integration context: `name`, and optional `version`, `operation`, and non-secret `resource` (SecretSpec 0.20+) | +| `purpose` | Structured resolver-client attribution (`consumer`, `operation`, and optional `host`/`path`); present only for resolver resolution in SecretSpec 0.20+. It is audit context, never identity or authorization input. | | `actor` | The OS user, the detected coding agent (if any), and whether this is an agent session | This pairs naturally with the [`require_reason`](/reference/configuration/#requiring-a-reason-for-secret-access) diff --git a/docs/src/content/docs/concepts/providers.mdx b/docs/src/content/docs/concepts/providers.mdx index 24ce28975..b9c252933 100644 --- a/docs/src/content/docs/concepts/providers.mdx +++ b/docs/src/content/docs/concepts/providers.mdx @@ -305,6 +305,12 @@ Provider credentials follow these rules: profiles should share one provider credential. - **Names are provider-specific.** The catalog above is exhaustive. Unsupported names are rejected before any source is read. +- **External providers negotiate names (0.20+).** An out-of-tree endpoint asks + for the URI-specific credentials it needs at runtime. Its alias does not need + a `credentials` table: SecretSpec can load requested values from a + provider-private operating-system keyring namespace, and `config provider + login` discovers and stores them. A configured mapping remains useful as an + explicit source override and is read only if the endpoint requests its name. - **A URI may not carry a credential (0.19+).** A provider URI with a password (`scheme://user:PASSWORD@host`) is rejected, as is a service account token in the `onepassword+token://` userinfo. A URI is committed to `secretspec.toml`, diff --git a/docs/src/content/docs/development/adding-providers.md b/docs/src/content/docs/development/adding-providers.md index eab222530..8768a2a14 100644 --- a/docs/src/content/docs/development/adding-providers.md +++ b/docs/src/content/docs/development/adding-providers.md @@ -86,6 +86,13 @@ I/O-free capability check. In particular, `prompt = true` selects how a missing value is acquired; `prompted_value_persistence` decides what the provider does with the answer. +In SecretSpec 0.20+, out-of-tree providers use the +[Secret Provider Protocol](/reference/provider-protocol) instead of linking an +implementation into this crate. Its adapter maps the versioned IPC operations +onto this trait; the [IPC implementation guide](/development/ipc-implementation) +lists the required trait bridges, discovery rules, and conformance tests. The +protocol is versioned independently from the endpoint's implementation. + In SecretSpec 0.19+, override `describe_write_target` when the provider URI and native coordinates do not identify the physical destination clearly. The `secretspec set` and interactive `secretspec check` commands print this diff --git a/docs/src/content/docs/development/ipc-implementation.md b/docs/src/content/docs/development/ipc-implementation.md new file mode 100644 index 000000000..713caa992 --- /dev/null +++ b/docs/src/content/docs/development/ipc-implementation.md @@ -0,0 +1,988 @@ +--- +title: Implementing SecretSpec IPC +description: Repository layout, handler design, trait mapping, conformance tests, and delivery order for IPC version 1 +--- + +This guide is the implementation plan for the +[IPC architecture](/reference/ipc-architecture), +[wire protocol](/reference/ipc-wire), +[Secret Resolution Protocol](/reference/resolver-protocol), and +[Secret Provider Protocol](/reference/provider-protocol). + +:::caution[Version compatibility] +These IPC implementation APIs and repository components are available starting +with SecretSpec 0.20. Release validation still requires the pure-C client, Rust +client/server, typed handlers, and conformance suite to pass on Linux, macOS, +and Windows. +::: + +## Required deliverables + +Version 1 is complete when the repository contains: + +1. protocol types, JSON Schemas, and OpenRPC descriptions independent of the + SecretSpec core; +2. a portable C11 client library with bounded framing, JSON-RPC calls, + cancellation, deadlines, child lifecycle, and shutdown; +3. a Rust client/server implementation, reusable resolution request handler, + and `secretspec serve`, including the client-callback direction and the + client-side handler that answers it; +4. an external-provider adapter plus endpoint-side handler API; +5. trusted provider registration and subprocess lifecycle support on all three + platforms; +6. golden fixtures, a black-box conformance runner, C/Rust differential + property tests, and native end-to-end tests; +7. C source plus static and shared client artifacts whose dependency closure + contains no Rust, resolver, or provider code. + +The wire protocol is the product contract. Rust handler traits and C client +symbols are implementations of it and may evolve compatibly without changing +the wire version. + +## Protocol v1 freeze gate + +Protocol version 1 becomes immutable when SecretSpec 0.20 is released. Before +that tag, all of these checks are required: + +1. An implementer who did not author the Rust client must review the wire + specification from the perspective of an independent client or endpoint. +2. Every request, response, notification, and error shape must have one + canonical schema, at least one checked-in fixture, and matching OpenRPC + documentation where applicable. +3. The pure-C and Rust clients must pass the same black-box conformance cases + and their differential state-machine test on Linux, macOS, and Windows. +4. Cancellation, deadline, callback, shutdown, child-exit, and malformed-frame + races must have deterministic regression coverage; timing-only tests are + insufficient for a release gate. +5. The implementation and reference documentation must agree on supported + protocols, capabilities, error openness, limits, and replay behavior. A + known discrepancy blocks the release rather than becoming an undocumented + compatibility rule. +6. External-provider discovery and process launch must receive a platform + security review covering path replacement, ACL or mode inheritance, + environment construction, handle inheritance, and child reaping. + +After 0.20, a breaking correction uses a new protocol integer. A bug fix may +tighten rejection of input that version 1 already declares invalid, but must not +reinterpret a previously valid transcript. + +## Suggested repository layout + +```text +schema/ipc/v1/ + common.schema.json + resolver.schema.json + provider.schema.json + resolver.openrpc.json + provider.openrpc.json + fixtures/ + wire/ + resolver/ + provider/ + +libsecretspec-resolver/ + include/ + secretspec_resolver.h + src/ + frame.c + json.c + session.c + process_posix.c + process_windows.c + secure_memory.c + tests/ + CMakeLists.txt + meson.build + +secretspec-ipc/ # Full Rust client/server implementation + schema/ipc/v1/ # Packaged discovery descriptions (0.20+) + src/ + description.rs + frame.rs + client.rs + jsonrpc.rs + lifecycle.rs + server.rs + resolver.rs + provider.rs + tests/ + +secretspec/src/ + serve.rs + provider/external.rs + +conformance/ipc/ + README.md + runner/ + cases/ +``` + +`libsecretspec-resolver` is C11 and must not contain or link Rust. Its only +third-party dependency is a C JSON parser; the initial implementation uses +yyjson behind a private adapter, resolved from the system through pkg-config or +CMake, and never exposes yyjson types in the ABI or re-exports its symbols. +The library must not depend on the SecretSpec core, provider SDKs, CLI parsing, +cloud clients, keyrings, manifest parsing, TLS, networking frameworks, GLib, or +libuv. + +`secretspec-ipc` is an independent Rust implementation of the same client and +server state machines plus typed handler traits. It does not call the C library. +The two implementations share schemas, fixtures, and tests—not implementation +code—so differential testing can expose interpretation differences. + +Keeping application wire types outside the core prevents a dependency cycle: + +```text +libsecretspec-resolver (C client) <--- Nix and non-Rust resolver-mode SDK bindings + +secretspec-ipc (Rust client/server/handlers) <--- Rust SDK, core, CLI/resolver, + provider adapter or endpoint +``` + +## Schema-first implementation + +Write JSON Schema Draft 2020-12 documents, matching +[OpenRPC](https://spec.open-rpc.org/) method descriptions, and checked-in JSON +fixtures before the handlers. Schemas must use closed objects +(`additionalProperties: false`), integer bounds, string byte-length checks in +code, and tagged unions for addresses and results. OpenRPC documents enumerate +methods and reference the same schemas; they must not define a second copy of a +request or result shape. + +The schema set should define: + +- JSON-RPC request, response, notification, and error envelopes; +- initialization for `secretspec.resolver/1` and `secretspec.provider/1`; +- every method's parameter and result object; +- the common error-kind enum; +- convention/native addresses and native coordinates; +- resolved value/path/missing/undeclared result variants; +- provider capabilities and metadata. + +JSON Schema validates characters, not UTF-8 byte counts, duplicate keys, frame +length, deadlines, capability selection, request-ID reuse, or exactly-one +terminal behavior. The codec and session state must enforce those separately. + +Generate or test Rust serialization against the schemas, but do not generate +the public protocol solely from Rust types. Golden JSON is the language-neutral +source of truth. CI validates fixtures against the JSON Schemas, parses the +OpenRPC documents, and compares their method catalogs with the Rust protocol +constants. + +## Frame codec + +Implement framing before RPC dispatch. The reader state machine is: + +1. read bytes through LF into zeroizing storage; +2. reject an empty line, CR, or a line that reaches the active limit before LF; +3. treat EOF after any JSON byte as truncation; +4. validate UTF-8, duplicate keys, nesting, and one-object shape; and +5. deserialize into the closed request/notification JSON-RPC envelope. + +The writer accepts already serialized payloads, verifies their size, and writes +one JSON line plus LF under one writer task. Neither layer logs payloads. + +Before initialization the active limits are one 1,048,576-byte frame and one +in-flight request. Swap to the negotiated limits only after the successful +initialization response has been committed. + +## Dispatcher and terminal-state ownership + +The read loop must remain live while operations run; a sequential +read-handle-write loop cannot receive cancellation for a blocked request. + +Use one session table keyed by request ID. Each entry owns: + +- an atomic state: `running`, `terminal_committed`, or `abandoned`; +- a cancellation token; +- a monotonic deadline; +- the in-flight semaphore permit; +- a response sender to the single writer; +- zeroizing request storage where practical. + +Only one `complete(id, outcome)` function may transition `running` to +`terminal_committed` and enqueue a response. Normal completion, explicit +cancellation, and deadline expiry all call it. A late handler loses the compare +and discards its output. Disconnect changes all running entries to `abandoned` +and cancels them without attempting writes. + +The semaphore permit remains held until the underlying task actually exits, +even if cancellation has already produced a terminal response. This prevents a +series of cancelled, non-cooperative blocking calls from creating unbounded +threads. New work receives bounded `unavailable` responses while capacity is +exhausted. + +The synchronous `Provider` trait means some handlers will run through a bounded +blocking pool. Cancellation is cooperative where a backend supports it and +best-effort elsewhere. Never claim cancellation rolled back a mutation. + +## Reusable handler API + +Keep transport and application logic separate. The exact Rust names may vary, +but the abstraction should have this shape: + +```rust +pub struct RequestContext { + pub request_id: u64, + pub deadline: Instant, + pub cancellation: CancellationToken, + /// Calls back to the client on this session (0.20+). Bounded by this + /// request's deadline and cancellation, so a callback cannot outlive the + /// request that raised it. + pub peer: Peer, +} + +pub trait ApplicationHandler: Send + Sync + 'static { + fn protocol(&self) -> &'static str; + fn versions(&self) -> &'static [u32]; + fn initialize( + &self, + context: &RequestContext, + application: serde_json::Value, + ) -> impl Future> + Send; + fn call( + &self, + context: RequestContext, + method: &str, + params: serde_json::Value, + ) -> impl Future> + Send; + fn shutdown(&self) -> impl Future + Send; +} +``` + +`Peer` is the one place version 1 reverses direction. A handler asks the client +something only when the client advertised the method in `client_methods`, +which it checks with `peer.supports(..)` rather than calling and handling the +refusal: a consumer that cannot reach a person needs that answer immediately, +not after its deadline. The peer must hold a *weak* reference to the writer +channel, or the session's own sender is no longer the last one and the writer +task never observes the close. + +The protocol crate should provide typed resolution/provider handler traits on +top of this lower-level dispatcher so endpoint authors do not parse JSON-RPC or +manage terminal races themselves. Endpoint mains call `serve_resolver` or +`serve_provider` directly with that typed handler; the internal JSON adapter is +not another public assembly step. + +The endpoint-facing provider API should accept owned, zeroizing values and +canonical owned addresses. It should expose one operation enum or individual +methods matching the provider protocol. This API is for out-of-tree endpoints; +in-tree compiled providers continue to implement the ordinary SecretSpec +provider trait directly. + +## Core changes for the resolver + +The current `Secrets::resolve_named` persists an `as_path` temporary file and +returns its path without retaining an owner. The resolver requires an internal +owned variant, for example: + +```rust +pub(crate) enum OwnedNamedResolution { + Undeclared, + Missing { required: bool }, + ResolvedValue(ResolvedSecret), + ResolvedFile { + metadata: ResolvedSecretMetadata, + file: tempfile::NamedTempFile, + }, +} +``` + +Both the embedded and resolver paths should call one least-access resolution +implementation: + +- the embedded API may keep/persist the file to preserve current behavior; +- the resolver inserts the owner into a session lease table and returns only its + protected path plus an opaque lease ID; +- cancellation or response-write failure drops the owner immediately; +- release and session shutdown remove owners from the table. + +Do not implement the resolver by calling the existing one-shot JSON FFI. That API +cannot recover ownership of a persisted file and would make disconnect cleanup +impossible. + +The resolver builder must consume only the immutable initialization +configuration. It must not fall back to its process working directory or +ambient profile/scope/reason variables. + +Thread the client request's structured purpose into the resolver's protected +audit context without using it as identity, authorization, or a replacement for +`reason`. Extend owned resolved metadata with two optional absolute timestamps: +provider-reported secret validity and resolver cache refresh. Preserve validity +through caches, cap freshness at known validity, and serialize null when either +bound is unknown. Neither timestamp owns a materialized file—the lease table +does. + +Three further resolver behaviors do not follow from the embedded API: + +- **Rejection** retains the exact provider routes and addresses that contributed + to the most recent successful resolution, reports refusal to each provider, + then invalidates derived caches for the name and its composition dependencies. + The provider—not the consumer or resolver—authorizes and chooses any backend + response. A read-only endpoint still answers because the report grants no + mutation authority, and every unknown/no-target reason returns the same + success. Expiry cannot cover early revocation. +- **Prompting** replaces the resolver's controlling-terminal reader, which in + resolver mode would open a terminal belonging to the launching process rather + than to the resolver. Scope the substitute reader to the blocking worker + serving one request, not to the shared `Secrets`, so a prompt inherits the + deadline and cancellation of the read that raised it and cannot be answered on + behalf of another request. +- **Read-only** is not just withholding the mutation methods. Resolving a + `generate = true` or `prompt = true` declaration with no stored value writes + the produced value back, so a read-only session must refuse those before the + value is generated or asked of a person. Producing without storing, and + populating SecretSpec's own cache, both remain allowed. + +A resolver should also suppress the progress lines generation and prompting +write to stderr. Those name which secrets a session provisioned, and the wire +protocol requires a host to treat endpoint stderr as sensitive. + +## External-provider adapter + +Add one `ExternalProvider` implementing the core `Provider` trait and backed by +a provider-protocol session. The adapter mapping is: + +| Core behavior | Protocol behavior | +| --- | --- | +| `name`, credential-free `uri`, storage/container identity, persistence policy | initialization metadata | +| `supported_coords` | `supported_coordinates` metadata | +| `convention_address`, `entry_coordinates` | `provider.resolve_address` | +| `get` plus secret-validity metadata | `provider.get` | +| `get_many` plus per-value validity metadata | `provider.get_many`, otherwise bounded `get` fallback | +| `set` | optional `provider.check_writable`, then `provider.set` | +| `set_expiring` | optional preflight, then `provider.set_expiring`; documented core fallback when absent | +| `delete` | optional `provider.check_deletable`, then `provider.delete` | +| `describe_write_target` | protocol method or resolved-coordinate rendering | +| `reflect` | `provider.reflect` | +| `physical_store_path` | initialization metadata | + +Two existing trait signatures assume compile-time provider metadata and need a +small compatibility seam before a dynamic adapter is sound: + +- change `Provider::name()` from `&'static str` to a borrow tied to `&self`, so + the adapter can return the validated endpoint name it owns; +- add a dynamic `supports_coord(&self, name: &str) -> bool` hook and make + `resolve_coords` use it. Its default can consult the existing static + `supported_coords()` list, while the external adapter consults the + initialization bitset. This avoids leaking runtime strings merely to satisfy + a `'static` return type. + +In-tree implementations keep their static names and coordinate slices; these +changes only relax the trait boundary. Add compile tests for direct providers, +`Box`, `Arc`, and the preflight wrappers. + +Two protocol capabilities are not represented directly by the current trait: + +- `provider.exists`: add a capability-aware presence seam if write-only + providers are to participate in `check` and import without exposing values. + Until the core commands understand that seam, they must reject write-only + use rather than reporting a false miss. +- `provider.clear`: add a bounded `ClearScope` trait method for cache providers + or keep the operation on a cache-specific external adapter. Never emulate it + with an unbounded reflection/list operation. + +Capability checks must happen before method dispatch. A missing operation +becomes `ProviderOperationFailed` with a locally generated, non-secret message; +it must not be treated as `None`, `false`, or success. + +Provider endpoints are blocking from the current trait's perspective. Share a +session safely across `Arc` wrappers, keep the response reader independent of +calling threads, and make interruption/cancellation callable from another +thread. + +Delay endpoint initialization until `with_base_dir`, the credential broker +(0.20+), +and the initial `set_reason` have been applied. The initialization request does +not carry an eager credential map: the endpoint calls `client.credential` for +the URI-specific semantic names it needs, and the adapter answers from explicit +alias mappings or its provider-private keyring namespace. If a live provider instance receives +a different reason later, close its endpoint and lazily open a new session; +session initialization is immutable. + +## Provider registration implementation + +Create one registration loader with platform path adapters, not three subtly +different discovery algorithms. Its inputs should be explicit: + +```rust +pub struct ProviderDiscovery { + pub explicit: BTreeMap, + pub user_directory: Option, + pub system_directory: Option, + pub allow_path: bool, +} +``` + +For each registration: + +1. open the file without following an attacker-controlled final symlink where + platform APIs permit; +2. bound its size before parsing; +3. parse the executable claim and verify the scheme/file-name match; +4. require an absolute executable and no shell metacharacter interpretation; +5. inspect owner and permissions/ACL according to whether the directory is + user or system scoped; +6. canonicalize and retain the resolved executable identity; +7. launch that exact target with fixed arguments and private pipe handles. + +Starting with 0.20, the claim intentionally declares only executable identity. Credential names +depend on the configured URI and evolve with the provider independently of its +installation record. Validate semantic names when the endpoint requests them, +bind every request to the discovered scheme, and bound the number of distinct +requests per session. A configured credential mapping for an external alias is +valid when its name has the semantic-name shape; it becomes authorized only if +that endpoint actually requests the same name. + +Provider construction checks the compiled in-tree registry first and only then +uses external discovery. Update `provider_from_url`, known-provider checks, +display-name lookup, and credential-name lookup together so planning and actual +construction cannot disagree. External registrations never shadow a compiled +provider scheme. + +The executable security check must be testable through an injectable platform +trait. Linux, macOS, and Windows tests need both accepted and rejected +ownership/permission fixtures. + +## Child lifecycle + +The C and Rust clients each implement the same observable lifecycle contract for +resolvers and provider endpoints: + +- create private stdin, stdout, and bounded stderr pipes; +- launch without a shell and retain a process handle; +- send initialization and wait for readiness within the startup timeout; +- run independent reader, writer, stderr-drain, deadline, and process-watch + tasks; +- on orderly close, send `rpc.shutdown`, close stdin, wait within the remaining + deadline, then terminate and reap; +- on crash, close the session, fail in-flight callers, release leases, and reap; +- never use detached cleanup threads. + +POSIX signals and Windows process termination are platform adapters to the same +observable contract. Tests assert outcomes and time bounds rather than a +particular signal name. The conformance harness applies each lifecycle script to +both clients and compares normalized outcomes. + +## Portable C client library + +`libsecretspec-resolver` is one of two reference clients and the supported +non-Rust resolver-mode SDK boundary. It is authored in C11, not Rust compiled +behind C symbols. Release it as source, a static archive, and a shared library +for every supported native target. Use hidden visibility by default and export +only `secretspec_resolver_*` symbols. + +It implements the Secret Resolution Protocol and only that one. The Secret +Provider Protocol's client is always the SecretSpec resolver, which is Rust, so +a C client for it would have no consumer; the library rejects any other protocol +at initialization rather than carrying a second capability set nothing drives. + +The C client answers prompts, and does it without ever calling back into the +consumer. The no-callback rule below is not negotiable, so the answer is driven +by the caller: a session opened with the answer-prompts flag has the library +advertise the capability on its behalf, a waiting call reports that a prompt is +pending rather than blocking, and the caller takes the prompt, answers or +declines it, and waits again. A session without the flag advertises nothing, is +never asked, and treats an inbound request as the protocol violation it has +always been. + +The library owns the advertisement rather than accepting one in the +caller-supplied initialization JSON, so a consumer cannot claim a capability the +build could not answer. The convenience one-shot call form is refused on such a +session: it returns a result and drops its handle, so it has nowhere to resume +after a prompt. + +The public header uses opaque client, call, and prompt handles, explicit byte +lengths, library-owned output buffers, and size-tagged option structs. The +checked-in +[`secretspec_resolver.h`](https://github.com/cachix/secretspec/blob/main/libsecretspec-resolver/include/secretspec_resolver.h) +is canonical; its public surface has this shape: + +```c +#include +#include + +#define SECRETSPEC_RESOLVER_ABI_VERSION ((1u << 16) | 0u) + +typedef struct secretspec_resolver_client secretspec_resolver_client; +typedef struct secretspec_resolver_call secretspec_resolver_call; +typedef struct secretspec_resolver_prompt secretspec_resolver_prompt; + +typedef struct { + const unsigned char *data; + size_t size; +} secretspec_resolver_slice; + +enum { + SECRETSPEC_RESOLVER_DISCOVER_EXECUTABLE = 1u << 0, + SECRETSPEC_RESOLVER_INHERIT_ENVIRONMENT = 1u << 1, + SECRETSPEC_RESOLVER_ANSWER_PROMPTS = 1u << 2 +}; + +typedef struct { + uint32_t struct_size; + uint32_t abi_version; + uint32_t flags; + uint32_t reserved; + secretspec_resolver_slice executable; + const secretspec_resolver_slice *arguments; + size_t argument_count; + const secretspec_resolver_slice *environment; + size_t environment_count; + secretspec_resolver_slice initialize_params_json; + size_t max_stderr_bytes; +} secretspec_resolver_options; + +typedef enum { + SECRETSPEC_RESOLVER_OK = 0, + SECRETSPEC_RESOLVER_INVALID_ARGUMENT = 1, + SECRETSPEC_RESOLVER_UNAVAILABLE = 2, + SECRETSPEC_RESOLVER_IO = 3, + SECRETSPEC_RESOLVER_PROTOCOL = 4, + SECRETSPEC_RESOLVER_REMOTE_ERROR = 5, + SECRETSPEC_RESOLVER_CANCELLED = 6, + SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED = 7, + SECRETSPEC_RESOLVER_PROMPT_PENDING = 8 +} secretspec_resolver_status; + +typedef struct { + unsigned char *data; + size_t size; +} secretspec_resolver_buffer; + +uint32_t secretspec_resolver_abi_version(void); + +secretspec_resolver_status secretspec_resolver_client_open( + const secretspec_resolver_options *options, + uint64_t deadline_unix_ms, + secretspec_resolver_client **client, + secretspec_resolver_buffer *server_info, + secretspec_resolver_buffer *error); + +secretspec_resolver_status secretspec_resolver_call_start( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_call **call, + secretspec_resolver_buffer *error); + +secretspec_resolver_status secretspec_resolver_client_call( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error); + +secretspec_resolver_status secretspec_resolver_call_wait( + secretspec_resolver_call *call, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error); + +void secretspec_resolver_call_cancel(secretspec_resolver_call *call); +void secretspec_resolver_call_free(secretspec_resolver_call *call); + +secretspec_resolver_status secretspec_resolver_prompt_take( + secretspec_resolver_client *client, + secretspec_resolver_prompt **prompt, + secretspec_resolver_buffer *error); +secretspec_resolver_slice secretspec_resolver_prompt_params( + const secretspec_resolver_prompt *prompt); +secretspec_resolver_status secretspec_resolver_prompt_answer( + secretspec_resolver_prompt *prompt, + const unsigned char *value, + size_t value_size, + secretspec_resolver_buffer *error); +secretspec_resolver_status secretspec_resolver_prompt_decline( + secretspec_resolver_prompt *prompt, + secretspec_resolver_buffer *error); +void secretspec_resolver_prompt_free(secretspec_resolver_prompt *prompt); + +secretspec_resolver_status secretspec_resolver_client_close( + secretspec_resolver_client *client, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *error); +void secretspec_resolver_client_free(secretspec_resolver_client *client); +void secretspec_resolver_buffer_free(secretspec_resolver_buffer buffer); +``` + +The header defines flags for opt-in executable discovery, environment +inheritance, and prompt handling. With no inheritance flag, `environment` is +the complete child environment; with it, entries override inherited values. +Privileged callers use an absolute executable and a complete allowlisted +environment. Every input slice is borrowed only for the duration of its +function call. Every extensible options struct starts with its byte size and +header ABI version; fixed buffer and opaque handle types do not. Every returned +buffer is owned by the library and must be released with +`secretspec_resolver_buffer_free`. The library clears secret-bearing +allocations before release where the platform and optimizer permit. ABI major +and minor are encoded separately from every wire protocol version. + +Callers set `reserved` to zero. The library rejects unknown flags and option +bytes it cannot interpret rather than silently changing launch authority. It +sets every output buffer to `{NULL, 0}` before work and +`secretspec_resolver_buffer_free` accepts that value. Non-success statuses return a +stable redacted error object when one is available; no error buffer contains a +request or response body. + +`client_open` accepts the exact executable and argument vector, initialization +JSON, environment-inheritance policy, startup deadline, and resource limits. It +launches directly, never through a shell. `call_start` validates and copies its +inputs, allocates a unique wire ID, and returns without waiting for a response. +`call_wait` produces exactly one terminal result. `call_cancel` is safe from +another thread and queues `rpc.cancel`; it never claims that a completed or +non-cooperative mutation was rolled back. `client_call` is the synchronous +`call_start` plus `call_wait` convenience path for callers that do not need a +handle. + +Every request carries one absolute deadline in its wire envelope. C callers +supply it as the ABI argument; the library clamps it to the protocol's +300-second horizon before adding the envelope member, without modifying the +application parameters. + +Each client owns a bounded joinable I/O worker and a request table. The worker +continues reading while handlers run, serializes writes, correlates responses, +fires deadlines, watches the child, and commits each call terminal exactly once. +There are no callbacks into foreign runtimes, detached threads, mutable global +clients, or background work after `client_close` returns. Call handles remain +valid until explicitly freed; closing a client first makes all outstanding calls +terminal before joining its worker. + +`call_free` on a nonterminal, unwaited call requests cancellation and releases +the caller handle without blocking. The internal request entry remains until it +becomes terminal or the session closes. A caller must not free a call while +another thread is inside `call_wait` for that handle. `client_close` makes all +outstanding calls terminal and joins the worker; `client_free` never performs +unbounded I/O. + +Threading rules are explicit: + +- `call_start` may run concurrently for one client up to its negotiated limit; +- exactly one thread may wait on a given call; +- `call_cancel` may race that waiter and is idempotent; +- `client_close` excludes new calls, makes existing calls terminal, and may run + only once; +- `client_free` performs an emergency bounded close if necessary, forcibly + terminates and reaps an owned child, joins workers, and then frees the handle; +- no other function may race `client_free`. + +Use a private C JSON adapter around yyjson. Reject duplicate +keys, invalid UTF-8, excessive nesting, non-object envelopes, and overlong +strings explicitly; parser defaults are not the protocol contract. Never hand +write a partial JSON parser and never expose parser objects in the public ABI. + +Build and test with the strictest available warnings, ASan, UBSan, TSan where +supported, property-based malformed-input coverage, and Windows runtime +diagnostics. CI must inspect the static and shared dependency closure and fail +if any Rust artifact, resolver, provider SDK, TLS stack, or unrelated runtime +appears. + +Current language SDKs continue to use the embedded `libsecretspec`. Moving an +SDK to resolver mode is a separate explicit behavior and packaging change. Rust +uses `secretspec-ipc`; every supported non-Rust resolver-mode SDK binds +`libsecretspec-resolver` rather than implementing another client. + +## Rust client and server + +`secretspec-ipc` is a full independent Rust implementation, not bindings to the +C library. Keep framing, envelope validation, session transitions, and terminal +ownership in a runtime-independent state-machine core. An optional default +`tokio` feature supplies async stdio transports, direct child launch, reader and +writer tasks, deadlines, cancellation tokens, process watching, and typed +client/server APIs. + +The Rust client exposes generic opaque-JSON calls at the wire layer and typed +resolution/provider sessions above it. `ResolverSession` and +`ProviderSession` own child launch, initialization validation, transport, +advertised methods, and shutdown as one lifecycle object. The server exposes +the reusable handler API described earlier. Both layers use the checked-in +schemas and fixtures; no +Rust type definition becomes a second source of truth. + +The Rust implementation must satisfy the same observable rules as C: bounded +pre-initialization allocation, negotiated concurrency, continued reads while +handlers run, exactly one terminal result, no uncertain replay, direct launch +without a shell, bounded child reaping, and value-free errors and logs. The Rust +server additionally answers side-effect-free `rpc.discover` (0.20+). Runtime +discovery embeds the canonical OpenRPC and JSON Schema assets, rewrites their +references into one self-contained document, and must not invoke the +application handler's initialization hook. Its async API may differ +ergonomically from C call handles, but normalized wire and lifecycle outcomes +must agree. + +## Conformance suite + +The conformance runner must test any client, resolver, provider endpoint, or +codec through public bytes and process behavior. It should support a command +template so third-party implementations can run the same cases. Cases are +language-neutral but role-specific: + +| Target | Mandatory coverage | +| --- | --- | +| C client | Wire, client lifecycle, generic resolver calls, prompt handling, and C ABI cases | +| Rust client | The same wire, lifecycle, and generic call cases as the C client, plus typed-client cases | +| Rust server and handlers | Server wire/lifecycle cases plus resolution and provider semantics | +| Resolver and external provider endpoints | Their applicable server, lifecycle, and application cases | +| C/Rust pair | Identical generated client histories with normalized differential outcomes | + +"Both implementations pass conformance" means that the C and Rust clients pass +every common client case. It does not imply a C server API. Server cases apply +to the Rust implementation and every conforming endpoint. + +### Wire cases + +- one-byte-at-a-time NDJSON reads and fragmented lines; +- multiple frames in one OS read; +- zero, oversized, truncated, invalid UTF-8, malformed JSON, duplicate-key, + excessive-nesting, and batch-array frames; +- invalid, duplicate, and reused request IDs; +- side-effect-free `rpc.discover` (0.20+) before and after initialization, + including discovery followed by initialization with the next request ID; +- application request before initialize and repeated initialize; +- no common protocol version and invalid required-method advertisement; +- negotiated smaller frame and in-flight limits; +- unknown capability, method, top-level field, and parameter; +- cancellation-before-completion, completion-before-cancellation, deadline, + and three-way races, repeated thousands of times; +- one terminal response for every accepted request; +- non-cooperative cancelled handlers retaining capacity until they exit; +- EOF, orderly shutdown, stuck shutdown, and child crash; +- a session that ends at EOF without waiting out its shutdown timeout, since + nothing may keep the writer channel open once the session drops its sender; +- oversized and unterminated NDJSON lines without echoing their bytes; +- an error code and kind from a later revision decoded as an unnamed failure, + while a defined code paired with an undefined kind is still rejected; +- a callback the client advertised, answered while its originating request is + still in flight, and a callback the client did not advertise never sent; +- a canary secret absent from logs, stderr, errors, panic text, and fixtures. + +### C client ABI cases + +- dynamic and static linking from a C-only smoke program; +- runtime/header ABI agreement, compatible trailing option fields, unknown + flags, nonzero reserved fields, and invalid slice pointer/length pairs; +- exact executable/argument delivery, empty and allowlisted environments, + opt-in inheritance, and no shell interpretation; +- concurrent `call_start`, one waiter per call, cancellation racing a waiter, + abandoned `call_free`, and close racing outstanding calls; +- allocator ownership, null-buffer free, repeated open/close, emergency free, + worker joining, and absence of use-after-free under sanitizers; +- static/shared dependency inspection proving that no Rust, SecretSpec core, + resolver, or provider artifact is linked. + +### C/Rust differential cases + +The checked-in `ipc-client-conformance-driver` exposes independent `c` and +`rust` modes behind one small test-control protocol. The conformance runner +executes every common wire/client case against both modes as external +processes. For each generated differential case, it also runs the same abstract +action sequence against both implementations and compares normalized event +transcripts. The fake peer is deterministic and scriptable at the byte +boundary, including fragmented initialization and malformed-frame responses. + +Normalize only implementation-irrelevant data such as generated request IDs, +JSON object member order, platform process identifiers, and elapsed times within +the specified bound. Never normalize method names, parameters, error kinds, +terminal counts, retry behavior, frames, state transitions, or cleanup effects. + +Differential generation covers: + +- arbitrary read chunking, write backpressure, and several frames per read; +- valid and invalid envelopes, limits, capabilities, and application messages; +- concurrent calls, cancellations, deadlines, shutdown, EOF, and child exit in + every reachable state; +- abandoned handles/futures and late handler completion; +- the same scripted fake resolution and provider endpoints; +- semantic results plus observable lifecycle events, not byte-identical JSON + serialization. + +Implement the generator and shrinker in Rust with `proptest`. A generated +history is pure serializable data, independent of either client. The runner +executes that history first against `ipc-client-c` and then against +`ipc-client-rust`; it must not use one implementation to generate expectations +for the other. + +Strategies are state-aware so most histories exercise meaningful initialized +and in-flight states, while explicit raw-frame strategies cover invalid input. +Shrinking preserves the preconditions needed to reproduce the mismatch and +minimizes the history, frames, chunk boundaries, limits, and payloads. Every +failure prints the replay seed and saves the minimized history, C transcript, +Rust transcript, and fake-peer transcript as CI artifacts. The minimized case +then becomes a permanent regression fixture. + +The differential properties are: + +- C and Rust normalized outcomes are equal; +- every accepted request has at most one terminal response and has one after + the scripted peer terminates or the session is drained; +- cancelled, expired, or disconnected requests are never replayed; +- close/free leaves no owned child, worker, call, task, or lease alive; +- negotiated frame, in-flight, task, stderr, and allocation bounds hold; +- the canary secret never appears in errors, logs, stderr, or transcripts. + +The property runner launches both drivers directly as ordinary native host +processes and requires no external testing service. Every change runs a bounded +case count on Linux, macOS, and Windows; scheduled and pre-release CI increase +the case count using the same runner and retain every replay seed. + +The SecretSpec 0.20+ repository also links the pure-C client into the native +conformance test process and runs serialized echo, cancellation, and deadline +histories against that ABI and the independent Rust client through one +deterministic child peer. This bounded local differential property is additive +to, not a substitute for, the complete cross-platform driver matrix above. + +### Executable provider and resolver cases + +The checked-in provider matrix launches a deterministic stateful endpoint as a +real subprocess. One driver mode calls it through the public Rust provider +client and endpoint-author handler API; the other calls the same endpoint +through SecretSpec's external-provider adapter. The matrix covers shared frame +acceptance and rejection, every provider operation, provider-reported secret +expiry, store-enforced expiry, bounded clear, idempotent deletion, preflight, +reflection, cancellation, deadlines, structured error +preservation, non-replay of one-shot failures, endpoint crash, reconnect for +later work, and provider-URI and reason session isolation. + +Run it with: + +```console +cargo test -p secretspec-ipc-conformance --test provider_cases +``` + +The resolver cases are executable test data too. Their integration driver +launches the actual `secretspec serve` binary, initializes it with inline +manifests and explicit provider/profile selection, and verifies exact-name +value, missing, undeclared, and file results. It checks owner-only file mode, +duplicate release, explicit lease removal, removal of an unreleased lease when +the session closes, and interactive versus headless prompt handling. + +Run it with: + +```console +cargo test -p secretspec --test ipc_resolver +``` + +### Resolution cases + +- path and inline manifests; reject relative paths and implicit discovery; +- fixed provider/profile/scope/reason session configuration; +- mandatory per-call purpose reaches protected audit context but never + authorization; +- exact-name resolution ignores an unrelated missing required secret; +- composed dependencies resolve but unrelated names are not read; +- undeclared and scope-hidden names have the same result; +- missing required and optional results; +- `auto`, `value`, and `path` representation matching; +- value results and all four source variants; +- known and unknown secret-expiry metadata plus independent cache-refresh + metadata, both independent of path-lease lifetime, with provider validity + preserved through cache hits; +- mode/ACL of materialized files; +- random opaque leases, duplicate release, release batching, disconnect + cleanup, cancelled-result cleanup, and response-write-failure cleanup; +- resolver crash and conservative stale-directory cleanup; +- no automatic replay after disconnect; +- a `prompt = true` declaration answered through the client callback and + persisted, and the same declaration resolving as missing, without a prompt + ever being sent, for a client that advertised none; +- a read-only endpoint refusing a resolution that would store what it produced, + while one that produces without storing still resolves. + +### Provider cases + +- registration precedence and scheme validation on all platforms; +- disallow PATH discovery in privileged mode; +- initialization URI/scheme mismatch and metadata redaction; +- convention and native addresses, all coordinates, unknown and unsupported + coordinates, and deterministic `resolve_address`; +- read hit/miss/error, provider-reported expiry, batch ordering/deduplication, + and batch fallback; +- write-only capability sets and `exists` without `get`; +- set, store-enforced expiry, idempotent delete, and bounded idempotent clear; +- mutation preflight agreement with the mutation itself; +- credential-free write descriptions and value-free reflection; +- cancellation/deadline during reads and mutations without replay; +- endpoint crash, relaunch for later work, and no failed-request replay. + +Property strategies cover the bounded-NDJSON decoder, JSON envelope parser, +every tagged union, and terminal-state races. Keep protocol fixtures free of +real credentials and use an unmistakable canary value for redaction assertions. + +## Delivery order + +Implement in reviewable stages: + +1. **Schemas and fixtures:** land versioned common, client, and provider + schemas, OpenRPC method documents, and golden examples. +2. **Pure-C client:** public header, framing, strict JSON-RPC envelopes, + process lifecycle, concurrent call handles, cancellation, shutdown, platform + adapters, sanitizers, and property-test driver. +3. **Rust client, server, and handlers:** independent client and server framing, + async transport, dispatcher, typed resolution and provider traits, plus + byte-for-byte fixture parity with the C implementation. +4. **Shared verification:** run every common client conformance case through + both clients and every server case through the Rust server, add normalized + C/Rust differential properties, shrinking, and replay fixtures. +5. **Resolution ownership:** add the internal owned named-materialization path + and lease table without exposing IPC yet. +6. **Resolver:** reusable resolution handler and `secretspec serve`, then + black-box lifecycle tests. Rejection, the client-callback direction and the + prompt that uses it, and read-only write refusal all belong to this stage, + because each is resolver behavior with no embedded-API equivalent. +7. **Provider handler:** endpoint-author API and a deterministic fake endpoint + that implements every capability. +8. **External adapter and discovery:** use the Rust IPC client for the trait + bridge, registrations, readiness, crash behavior, and native platform tests. +9. **Consumer activation:** integrate Nix and non-Rust resolver-mode SDKs through + the released C client, and Rust consumers through the released Rust client, + only after their conformance and differential gates pass. + +Each stage should leave the existing embedded SDK and in-tree provider paths +passing unchanged. + +## Release checklist + +- [ ] Schemas, C envelopes, Rust client/server types, fixtures, and rendered + documentation agree. +- [ ] The C client has no Rust, SecretSpec core, resolver, or provider + dependency. +- [ ] Static and shared C artifacts and the public header build on Linux, macOS, + and Windows. +- [ ] The independent Rust client, server, and typed handlers build on Linux, + macOS, and Windows with the documented async runtime support. +- [ ] Every common client conformance case passes against both clients, every + applicable server case passes against the Rust server and endpoints, and + normalized C/Rust outcomes agree for differential histories. +- [ ] Differential failures retain a reproducible seed, minimized action trace, + and redacted byte transcript. +- [ ] The native property runner needs no external service and its bounded, + scheduled, and pre-release case sets report no counterexample. +- [ ] Frame, in-flight, task, stderr, and shutdown bounds are enforced. +- [ ] Cancellation is readable while an operation is running. +- [ ] Every accepted request has exactly one terminal response in race tests. +- [ ] A session ends at EOF without waiting out its shutdown timeout. +- [ ] Unknown error kinds and unknown descriptive result values decode rather + than failing the frame, so a later revision can add one; strict request + parsing is unchanged. +- [ ] A callback is sent only when the client advertised it, inherits its + originating request's deadline and cancellation, and both sides keep + reading while one is outstanding. +- [ ] A read-only endpoint writes nothing to a provider, including the values a + resolution would otherwise generate or prompt for and store. +- [ ] No uncertain request is automatically replayed. +- [ ] Named resolution retains file ownership until lease release or session + cleanup. +- [ ] Provider discovery and lifecycle pass on Linux, macOS, and Windows. +- [ ] Write-only providers fail value reads instead of returning false misses. +- [ ] Clear is demonstrably bounded to the initialized provider namespace. +- [ ] Error and logging tests never expose values, names, addresses, URIs, + credentials, paths, or backend bodies. +- [ ] Version 1 endpoint principal semantics are documented for external + providers and no forwarded JSON identity is trusted. +- [ ] C warnings, sanitizers, Windows diagnostics, Rust formatting/lint, docs, + schema, conformance, property, and native end-to-end checks pass. diff --git a/docs/src/content/docs/development/sdks.md b/docs/src/content/docs/development/sdks.md index 6fca089c5..d95d62137 100644 --- a/docs/src/content/docs/development/sdks.md +++ b/docs/src/content/docs/development/sdks.md @@ -9,12 +9,18 @@ together, how each one is packaged and released, which platforms each artifact covers, and what to update when adding a platform or a new SDK. For the user-facing architecture and API, see the [SDK overview](/sdk/overview). +:::note[Native library name] +Starting with SecretSpec 0.20, the embedded C ABI is named `libsecretspec` and +ships `libsecretspec.*` artifacts. Releases through 0.19 used +the component name `secretspec-ffi` and `secretspec_ffi` artifact stem. +::: + ## One resolver, many packages All resolution logic lives in the `secretspec` Rust crate. The SDKs reach it two ways: -- **Through the C ABI** (`secretspec-ffi`, which builds a `cdylib` for dynamic +- **Through the C ABI** (`libsecretspec`, which builds a `cdylib` for dynamic loading and a `staticlib` for embedding): Ruby (mkmf extension statically links the archive), Go (purego `dlopen` of the cdylib, or cgo against the archive with `-tags static`), Haskell (GHC FFI against the archive), C# @@ -31,6 +37,15 @@ cross-language conformance suite (`conformance/`, run by `.github/workflows/sdks.yml` on every PR) asserts they all reduce the same inputs to the same result. +This embedded boundary remains supported. SecretSpec 0.20+'s +[IPC architecture](/reference/ipc-architecture) adds an explicit resolver option +for applications that cannot or should not link the resolver and its provider +graph. Its [Secret Resolution Protocol](/reference/resolver-protocol) is a +versioned process boundary; it does not replace `libsecretspec` or silently +change how existing SDK packages run. See +[Implementing SecretSpec IPC](/development/ipc-implementation) for the 0.20+ +component layout and conformance requirements. + Package versions for the non-Rust SDKs are not hand-edited: release workflows run `scripts/sync-sdk-versions.sh`, which stamps the Cargo workspace version into every package manifest. @@ -45,11 +60,11 @@ platform and publishes on a version tag: | Rust | `secretspec` on crates.io (source) | `publish.yml` | | Python | `secretspec` wheels on PyPI | `python-wheels.yml` | | Node.js | `secretspec` + per-platform packages on npm | `node-addon.yml` | -| Go | Go module (source) + `secretspec-ffi` release assets | `go-embed.yml`, `go-static.yml`, `ffi-build.yml` | +| Go | Go module (source) + `libsecretspec` release assets | `go-embed.yml`, `go-static.yml`, `ffi-build.yml` | | Ruby | `secretspec` platform gems on RubyGems | `ruby-gems.yml` | | C# | `Cachix.SecretSpec` on NuGet | `dotnet-package.yml` | | Swift (0.18+) | SwiftPM source package + XCFramework release asset | `swift-package.yml` | -| PHP | Composer package (source) + prebuilt extension binaries and `secretspec-ffi` release assets | `php-ext.yml`, `ffi-build.yml` | +| PHP | Composer package (source) + prebuilt extension binaries and `libsecretspec` release assets | `php-ext.yml`, `ffi-build.yml` | | Haskell | `secretspec` on Hackage (source) | `haskell-build.yml` | ## Platform support @@ -77,7 +92,7 @@ Notes: The keyring provider uses a Rust-native D-Bus transport on Linux and does not require system libdbus. - Hackage distributes source only; the Haskell column records which platforms - CI builds and tests, since users link `secretspec-ffi` themselves. + CI builds and tests, since users link `libsecretspec` themselves. - The Swift package targets macOS 12+ only. Its XCFramework contains native Intel and Apple-silicon slices; mobile Apple platforms are intentionally out of scope for a development-workflow resolver that launches provider CLIs and @@ -88,7 +103,7 @@ Notes: Swift interoperates with C directly through Clang modules, and SwiftPM distributes native Apple binaries as XCFramework binary targets. That fits the -existing `secretspec-ffi` boundary exactly: three ownership-audited C functions +existing `libsecretspec` boundary exactly: three ownership-audited C functions carry one already-versioned JSON contract. [UniFFI](https://mozilla.github.io/uniffi-rs/latest/) is a good default for a @@ -136,11 +151,11 @@ GHC's linker at them. ## Linking through pkg-config (0.19+) -`secretspec-ffi/scripts/cinstall.sh PREFIX static|shared` uses +`libsecretspec/scripts/cinstall.sh PREFIX static|shared` uses [cargo-c](https://github.com/lu-zero/cargo-c) to install one library type, the -header, and a `secretspec_ffi.pc` carrying its full link line. This lets +header, and a `libsecretspec.pc` carrying its full link line. This lets pkg-config consumers skip the `native-static-libs` capture above. Use separate -prefixes for the two modes: both metadata files use `-lsecretspec_ffi`, and the +prefixes for the two modes: both metadata files use `-lsecretspec`, and the linker prefers a shared library when both forms are present. ## Adding a platform to an SDK @@ -177,3 +192,11 @@ linker prefers a shared library when both forms are present. 6. Follow the same release-visibility rules as providers: label everything with the target version until the release ships (see [Adding Providers](/development/adding-providers)). + +If the SDK offers resolver mode (SecretSpec 0.20+), keep it an explicit backend choice and run the +IPC conformance suite in addition to the embedded SDK suite. Resolver mode must +use the Rust `secretspec-ipc` client in Rust or bind the pure-C +`libsecretspec-resolver` client in non-Rust SDKs. The wire protocol remains canonical +for independent implementations, but supported language bindings must not +create a different request format or another client state machine. CI runs both +clients through the same conformance and differential cases. diff --git a/docs/src/content/docs/integrations/adding.md b/docs/src/content/docs/integrations/adding.md new file mode 100644 index 000000000..4fc0523fd --- /dev/null +++ b/docs/src/content/docs/integrations/adding.md @@ -0,0 +1,207 @@ +--- +title: Adding an Integration +description: Choose a mechanism for making an external tool read secrets from SecretSpec, then build it +--- + +An integration lets a tool that is not SecretSpec read its secrets from a +SecretSpec provider, instead of keeping a second copy in that tool's own store. +[Git credentials](/integrations/git/) is the first one SecretSpec ships. + +There is no single way to build one. Which mechanism fits depends on how the +tool takes input, whether you can change its source, and whether it needs a +value once at startup or repeatedly while it runs. Pick from the table, then +read the matching section. + +## Choose a mechanism + +| Mechanism | Use when | You write | Ships in | +| --- | --- | --- | --- | +| [Environment injection](#environment-injection) | The tool reads environment variables or a file path | Nothing | Available today | +| [An SDK](#link-an-sdk) | You control the tool's source and can link a library | Calls into the SDK | Available today | +| [IPC](#resolve-over-ipc) | A separate program needs on-demand resolution, prompting, or write-back, and must not link SecretSpec | A protocol client | 0.20+ | +| [An in-tree integration](#ship-an-integration-inside-secretspec) | The tool speaks its own credential or helper protocol, and the integration should ship with SecretSpec | A module and a shim binary | 0.20+ | + +Work down the list. Environment injection costs nothing and covers most tools, +so reach past it only when something concrete rules it out: the tool needs a +value it did not have at startup, it must not hold the value in its +environment for its whole lifetime, or it has a credential protocol of its own +that expects to be asked. + +## Environment injection + +The shortest path. `secretspec run` resolves the profile and executes the tool +with the values in its environment: + +```bash +$ secretspec run -- terraform apply +``` + +Narrow what the tool receives with a [scope](/concepts/scopes/), so a single +manifest can serve several tools without handing each one everything: + +```bash +$ secretspec run --scope deploy -- terraform apply +``` + +For a tool that wants a file rather than a variable, declare the secret with +`as_path = true` and it receives a path to a resolver-owned temporary file. + +This mechanism resolves once, before the tool starts. It cannot re-resolve a +rotated value, ask a person for input mid-run, or write a value back. When one +of those matters, keep reading. + +## Link an SDK + +If you own the tool's source, link SecretSpec directly rather than wrapping it. +The SDK resolves on demand inside the process, with no subprocess and no +protocol to implement. See the [SDK overview](/sdk/overview/) for the available +languages and how each one ships. + +This is the right answer for your own applications. It is the wrong answer for +a third-party tool you do not control, and for any consumer that must not take +on SecretSpec's dependency closure. + +## Resolve over IPC + +:::caution[Version compatibility] +`secretspec serve`, the IPC libraries, and protocol version 1 are available +starting with SecretSpec 0.20. +::: + +IPC covers the case the other two cannot: a separate, long-lived program that +resolves names while it runs, without linking SecretSpec or any provider SDK. + +The consumer launches [`secretspec serve`](/reference/cli/#serve-020) as a +child process and speaks the +[Secret Resolution Protocol](/reference/resolver-protocol/) over its standard +input and output. A session is bound at initialization to one manifest, +provider, profile, scope, and access reason, and from then on resolves one +exact declared name at a time. That bound scope is what makes IPC safe to hand +to a consumer you would not hand a whole profile. + +Beyond resolution, a session can: + +- return a resolver-owned file path under an explicit lease, for a value that + should not pass through an environment variable; +- ask the launching process for a value through the `client.prompt` callback, + since the resolver has no terminal of its own; +- store or remove one declared name with `resolver.set` and `resolver.delete`, + for a tool such as `cargo login` that authenticates and then wants to keep + the result. + +Optional callbacks and mutations are advertised as capabilities, so a client +can tell an older endpoint apart from one that refused a particular request. +Launch with `secretspec serve --read-only` when the consumer must never cause a +write. + +You have three ways to write the client: + +| Client | Language | Notes | +| --- | --- | --- | +| `secretspec-ipc` | Rust | Async by default; its `blocking` feature gives a synchronous session for a program with no async runtime | +| `libsecretspec-resolver` | C11 | Portable source plus static and shared libraries, with no Rust in its dependency closure | +| Your own | Any | Implement the [wire protocol](/reference/ipc-wire/) directly | + +Read the [IPC architecture](/reference/ipc-architecture/) first for the trust +boundaries, then the protocol pages for the contract. + +:::note[Integrating a secret store, not a consumer] +If the thing you are connecting *holds* secrets rather than consuming them, you +want the [Secret Provider Protocol](/reference/provider-protocol/) or a native +[provider](/development/adding-providers/), not an integration. The distinction +is direction: a provider answers SecretSpec, an integration asks it. +::: + +## Ship an integration inside SecretSpec + +:::caution[Version compatibility] +The integration module layout described here is available starting with +SecretSpec 0.20. +::: + +Some tools already define how they ask for a credential. Git invokes a helper +binary and exchanges attributes with it on stdin and stdout; other tools have +their own equivalents. For those, the integration belongs inside SecretSpec, so +a user installs one thing and configures it with `secretspec`. + +Integrations live in `secretspec/src/integration/`. Read +`integration/git.rs` alongside this section: it is the worked example, and it +is the only one so far. + +### 1. Embed a manifest + +An integration must not depend on the current working directory. A user running +`git push` from any directory expects the same credential, so the integration +carries its own manifest rather than discovering one: + +```rust +const EMBEDDED_MANIFEST: &str = include_str!("git-credentials.toml"); +``` + +Keep it small. The Git manifest declares exactly two secrets, a required +`PASSWORD` and an optional `USERNAME`, and sets `require_reason = false` +because the tool invoking the helper cannot supply a reason. + +### 2. Derive a stable identity + +One embedded manifest usually has to serve many targets: several Git hosts, +several accounts on one host. Derive an identity from the canonical attributes +of the target and use it to isolate values. + +Rewrite the project name *and* suffix the key names: + +```rust +config.project.name = format!("git-credential-{identity}"); +let password_secret = format!("{EMBEDDED_PASSWORD}_{identity}"); +``` + +Both are necessary. Some providers flatten a convention address to the logical +key and ignore project and profile entirely, so an identity carried only in the +project name would collapse every target onto one value in those stores. + +Canonicalize before hashing, so that equivalent spellings of the same target +select the same credential and genuinely different targets never collide. + +### 3. Add the shim binary + +Register the binary the tool will invoke in `secretspec/Cargo.toml`: + +```toml +[[bin]] +name = "git-credential-secretspec" +path = "src/bin/git-credential-secretspec.rs" +required-features = ["cli"] +``` + +Keep it a thin entry point that delegates into the integration module, and +match the primary CLI's behavior for the environment it runs in. The Git shim +restores default `SIGPIPE` handling so it exits quietly when its output pipe +closes. + +### 4. Add CLI subcommands + +The shim serves the tool. A `secretspec ` subcommand group serves the +person, and should cover the whole lifecycle: + +```bash +$ secretspec git configure --url https://github.com --username YOUR_USERNAME +$ secretspec git login https://github.com +$ secretspec git logout https://github.com +$ secretspec git unconfigure --url https://github.com +``` + +Where the integration writes into the tool's own configuration, treat that file +as shared. Register alongside existing entries rather than replacing them, +write durably, preserve symlinks, and leave recoverable state when removal +fails partway. + +### 5. Document it + +Add `docs/src/content/docs/integrations/.md`, then add it to the +`Integrations` group in `docs/astro.config.ts`. State what the integration does +not cover: the Git page says up front that it does not manage SSH keys or +inject secrets into repositories, which saves a reader from discovering that +later. + +Mark every new command, field, and page with its target version, as described +in the [release visibility checklist](/development/adding-providers/#documentation-and-release-visibility). diff --git a/docs/src/content/docs/providers/systemd-credential.md b/docs/src/content/docs/providers/systemd-credential.md index 8c19bf0be..ac79f4a74 100644 --- a/docs/src/content/docs/providers/systemd-credential.md +++ b/docs/src/content/docs/providers/systemd-credential.md @@ -105,7 +105,7 @@ SecretSpec reads it into memory and hands it directly to the target provider. Every process in the same service runs under the same credential access boundary. If `secretspec run` starts the application in that service, the application can also access the service's credential directory. Put a -high-value bootstrap credential in a separate SecretSpec broker or provisioning +high-value bootstrap credential in a separate SecretSpec resolver or provisioning service when the application itself must not be able to read it. ::: diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 2fd301527..ddb0ee901 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -200,7 +200,13 @@ $ secretspec config global provider remove prod_vault # 0.17+ ``` ### config provider login -Store the [credentials](/reference/provider-credentials/) a provider alias declares. Prompts (hidden input) for each credential and writes it to its source provider at the exact location resolution reads it back from. Runs in a project, like `set` and `check`. +Store the [credentials](/reference/provider-credentials/) a provider alias +declares. Prompts (hidden input) for each credential and writes it to its source +provider at the exact location resolution reads it back from. For an external +provider with no mappings (0.20+), starts the endpoint, prompts for the +URI-specific credentials it requests, and stores them in SecretSpec's +provider-private operating-system keyring namespace. Runs in a project, like +`set` and `check`. :::note[Version compatibility] `config provider login` is available starting with SecretSpec 0.15. In @@ -224,7 +230,9 @@ Enter access_token for provider 'bws' (source: keyring): **** Run 'secretspec check --provider bws' to verify authentication. ``` -A read-only source provider is rejected. An alias that declares no credentials reports that there is nothing to store. +A read-only source provider is rejected. A built-in alias that declares no +credentials reports that there is nothing to store. An external alias with no +mappings may still request credentials dynamically (0.20+). ### git configure (0.20+) @@ -782,6 +790,48 @@ $ secretspec audit --action get -n 5 $ secretspec audit --json | jq 'select(.outcome == "missing")' ``` +### serve (0.20+) + +:::caution[Version compatibility] +`serve` is available starting with SecretSpec 0.20. +::: + +Run one private Secret Resolution Protocol session over standard input and +standard output. SDKs launch this command directly and communicate with bounded +newline-delimited JSON-RPC frames; it is not intended for interactive +terminal use or as a network listener. + +Before initialization, inspection tooling may call `rpc.discover` (0.20+) to +obtain the resolver's self-contained OpenRPC description. Discovery does not +load the selected manifest, contact a provider, prompt, or make the resolver +ready; ordinary SDK clients initialize immediately. + +```bash +$ secretspec serve [--read-only] +``` + +**Options:** +- `--read-only` - Resolve only, never write to a provider (SecretSpec 0.20+) + +The client supplies an absolute path or inline manifest plus immutable +provider, profile, scope, and reason selection during initialization. See the +[Secret Resolution Protocol](/reference/resolver-protocol/) for lifecycle, +least-access resolution, file leases, and reconnect rules. + +The session also answers `resolver.set` and `resolver.delete` (0.20+), which +store and remove one declared name where that same session resolves it. +`--read-only` withholds both, for an operator who wants a consumer to read a +store it may not change. + +Withholding those two methods is not on its own enough to make a session +read-only, so `--read-only` does more than that. Resolving a name is not always +a read: a `generate = true` declaration with no stored value is minted *and +written back*, and a `prompt = true` one is written back after a person answers. +A `--read-only` session refuses both rather than reaching the store, and the +resolve fails with `permission_denied`. A provider that produces such a value +without storing it is unaffected, since nothing is written. SecretSpec's own +cache is also unaffected: populating a derived copy does not change the secret. + ### completions (0.20+) :::caution[Version compatibility] diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 59a46d275..594f80d90 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -505,6 +505,12 @@ credentials = { role_id = { provider = "onepassword", ref = { vault = "Infra", Configured credentials take precedence over provider environment fallbacks, credential chains are limited to one hop, and a fetched credential is never written to the environment. Store the credentials with [`secretspec config provider login`](/reference/cli/#config-provider-login). See [Provider credentials](/concepts/providers/#provider-credentials) for the full behavior. +For an external provider (0.20+), `credentials` is optional and does not need +to enumerate every possible authentication method. The endpoint requests the +semantic names selected by its URI at runtime. A matching table entry overrides +SecretSpec's provider-private system-keyring lookup and is fetched lazily; an +entry the endpoint never requests is never read. + Starting with SecretSpec 0.19, a leaf alias may also compile logical secret names into that provider's native coordinates. Templates expand each placeholder once; text inserted from a project, profile, or key is never diff --git a/docs/src/content/docs/reference/ipc-architecture.md b/docs/src/content/docs/reference/ipc-architecture.md new file mode 100644 index 000000000..141e8c317 --- /dev/null +++ b/docs/src/content/docs/reference/ipc-architecture.md @@ -0,0 +1,311 @@ +--- +title: IPC architecture +description: Design and trust boundaries for SecretSpec client and external-provider IPC +--- + +SecretSpec needs two local IPC boundaries with different authority and method +sets: + +```text +application or SDK + | + | Secret Resolution Protocol + v +SecretSpec resolver + | + | Secret Provider Protocol + v +external provider endpoint ----> provider-owned service or agent +``` + +The protocols share the [IPC wire protocol](/reference/ipc-wire), but they are +not one API: + +| Boundary | Purpose | Authority | +| --- | --- | --- | +| [Secret Resolution Protocol](/reference/resolver-protocol) | Resolve an exact declared name through a complete SecretSpec configuration | The caller receives the resolved value or a leased path to it | +| [Secret Provider Protocol](/reference/provider-protocol) | Implement one provider behind SecretSpec's resolver | The endpoint receives provider addresses and values, but not SecretSpec's storage or resolver internals | + +The [implementation guide](/development/ipc-implementation) turns these +contracts into crates, handlers, tests, and an implementation order. + +:::caution[Version compatibility] +The IPC libraries, resolver, external-provider adapter, and protocol version 1 +are available starting with SecretSpec 0.20. +::: + +## Decision + +The canonical interface is the wire specification. SecretSpec ships two +first-class implementations: + +- `libsecretspec-resolver`, a portable C11 client for the Secret Resolution + Protocol, distributed as source plus static and shared libraries. It is C, not + a C ABI over Rust. It speaks that one protocol: the Secret Provider Protocol's + client is always the SecretSpec resolver, so a C client for it would serve + nobody. +- `secretspec-ipc`, a Rust client/server crate with typed resolution and provider + handlers. + +Nix and non-Rust resolver-mode SDKs use the C client. Rust consumers, the +SecretSpec external-provider adapter, the resolver, and Rust provider endpoints +use the Rust implementation. Both implementations are tested against the same +language-neutral conformance suite and differential state-machine tests. + +The initial transport is a child process over inherited stdin and stdout on +Linux, macOS, and Windows. Messages are JSON-RPC 2.0 objects inside bounded +bounded newline-delimited JSON frames. A later Unix-domain socket or Windows named-pipe +transport may carry the same messages after separately specifying endpoint +authentication and discovery. + +### Why C and Rust implementations? + +A shared C client avoids duplicating the security-sensitive state machine across +non-Rust SDKs. C11 gives native consumers one stable ABI without imposing a Rust +toolchain or Rust static-link closure. The library uses opaque handles, +explicit-length buffers, and no callbacks into foreign runtimes, so C++, Go, +Swift, Python, and other bindings can wrap it. A native Rust implementation +avoids routing SecretSpec's own async handlers through FFI and gives Rust +consumers typed APIs. + +Neither implementation is the protocol. Independent or constrained clients may +implement the canonical wire contract directly, but every implementation must +pass the same conformance suite. The C and Rust implementations additionally +run differential tests so a shared bug is less likely to redefine the contract. + +The intended deliverables are therefore: + +- `libsecretspec-resolver`: the pure-C resolution client, process launcher, frame + codec, JSON-RPC session, and public C header, with no Rust or provider + dependencies; +- `secretspec-ipc`: Rust wire types, client, server dispatcher, process + lifecycle, and typed resolution/provider handlers; +- thin non-Rust SDK bindings around the C ABI for resolver mode; +- one language-neutral conformance suite plus C/Rust differential tests. + +The existing `libsecretspec` remains the embedded resolver ABI used by current +language SDKs. It is not silently changed into an IPC client. An SDK may later +offer `embedded` and `resolver` backends behind the same language-level API. + +### Why not Varlink? + +[Varlink](https://varlink.org/) supplies an interface language, local service +discovery conventions, framing, typed errors, streaming replies, and runtime +introspection. The maintained [zlink](https://docs.rs/zlink/0.7.0/zlink/) +implementation also provides async Rust clients and services, code generation, +and Tokio and smol integrations. SecretSpec does not use it for these reasons: + +- **No multiplexing.** Varlink pipelines calls in order but has no request IDs. + One blocked call delays later replies and cannot be cancelled independently. + A pool of connections avoids this, but adds channel scheduling, descriptor + limits, and replacement after cancellation. +- **Portability.** Version 1 requires private stdio sessions on Linux, macOS, + and Windows plus a pure-C client. zlink is Rust-only. systemd's + [`varlinkctl`](https://www.freedesktop.org/software/systemd/man/latest/varlinkctl.html) + can launch a private endpoint over an AF_UNIX socket pair passed as file + descriptor 3, but that Linux-specific process contract does not supply the + portable stdio and Windows behavior SecretSpec needs. +- **No bidirectional calls.** Varlink streams replies from a service, but does + not define a service issuing a new call to its client and awaiting the answer + on that connection. SecretSpec needs exactly that for `client.prompt` and + `client.credential` (0.20+); a second connection would add discovery, + correlation, authentication, and lifecycle state. +- **Missing lifecycle semantics.** SecretSpec would still have to define + version and capability negotiation, deadlines, cancellation, message bounds, + leases, shutdown, and the trust model. +- **No net simplification.** Varlink's direct-executable form avoids a named + socket, but not the portability, lifecycle, or callback work above. Its + persistent named-socket form additionally requires authentication, + permissions, discovery, and stale-socket cleanup. Connection pools replace + the current request-ID table with another security-sensitive state machine. + +See Varlink's documentation on +[ordered connections](https://varlink.org/FAQ.html#why-are-there-no-sequence-numbers-in-calls-and-replies) +and [transport-specific file descriptors](https://varlink.org/FAQ.html#can-i-transmit-file-descriptors). + +[JSON-RPC 2.0](https://www.jsonrpc.org/specification) gives SecretSpec stable +request correlation, method names, results, and errors while leaving transport +and application semantics explicit. The missing stream framing and +cancellation rules are small enough to specify here and implement without a +large runtime. + +SecretSpec adopts Varlink's most useful operator-facing property without +adopting its wire protocol: `rpc.discover` (0.20+) returns a self-contained +OpenRPC interface description before application initialization. It provides a +safe foundation for generic inspection tooling without loading a manifest, +opening a provider, or changing the private transport model. + +This is a portability and dependency decision, not a claim that Varlink is a +bad protocol. Varlink improves interface description and generated Rust APIs, +but does not simplify SecretSpec's required lifecycle. A Unix Varlink adapter +could still be added later without changing the canonical application methods. + +## Layer ownership + +The layers are independently owned and versioned: + +1. The wire layer owns frames, JSON-RPC envelopes, side-effect-free discovery + (0.20+), initialization, request IDs, deadlines, cancellation, shutdown, + common errors, resource bounds, and the callback direction an endpoint uses + to ask its client something. +2. `secretspec.resolver/1` owns resolver configuration, exact-name resolution, + value/path representations, and path leases. +3. `secretspec.provider/1` owns provider discovery, provider metadata, + canonical addresses, secret-validity expiry, provider operations, and + provider error mapping. +4. A provider owns its storage, encryption, synchronization, hardware keys, + grants, and any protocol used behind its endpoint. + +An external provider exposes only the SecretSpec endpoint contract. Its +storage engine, encryption, synchronization, hardware keys, and grants stay +behind that endpoint and are never modeled in SecretSpec IPC. In-tree providers +may instead call a provider-owned Rust client directly. + +Application methods from the two protocols must never be mixed on one +connection. Initialization selects exactly one protocol name and major +version. + +Version 1 reverses direction in exactly one place. A stdio endpoint has no +terminal, so when a value can only come from a person it asks the client that +launched it rather than reaching for one behind the protocol's back. The client +declares during initialization whether it can answer, and an endpoint never +sends a callback that was not declared. See the wire protocol's +[callbacks](/reference/ipc-wire#callbacks). + +## Process and transport model + +Version 1 has one mandatory transport: a directly launched child with a private +stdin/stdout pair. This choice has the same security shape on all target +platforms: + +- no global endpoint name is discoverable or connectable by another process; +- possession of the inherited pipe handles is the session authority; +- `rpc.discover` (0.20+) describes the endpoint but does not make it ready; +- readiness is the successful `rpc.initialize` response; +- EOF, `rpc.shutdown`, or child exit ends the session and releases every + session resource; +- executables are launched directly, never through a shell; +- secrets are sent only in framed requests and responses, never in command-line + arguments. + +The launcher owns environment inheritance policy. Interactive SDK use may +inherit the caller environment because providers currently use ambient +credentials. Privileged integrations should construct an allowlisted +environment. Protocol fields must not also be mirrored into environment +variables. + +A persistent service transport is intentionally not version 1. It requires a +separate specification for owner-only endpoint permissions, peer credentials, +endpoint selection, stale endpoint cleanup, multi-client fairness, and +platform-specific identity. Merely finding a socket or pipe is not +authorization. + +## Principal and delegation decision + +For protocol version 1, an external provider endpoint acts as its own +application principal when it connects to a provider-owned agent. This rule +does not apply to compiled providers, where the SecretSpec CLI or embedding +application is the process that connects to the provider-owned agent and is +therefore its authenticated principal. + +The endpoint must not authorize an original application identity copied from a +JSON field. A process path, PID, `application_id`, user name, or similar value +forwarded by the resolver is only an assertion and is not authenticated +delegation. + +The provider initialization context (0.20+) follows the same rule. Its project, +profile, base directory, reason, and requested authorization duration let an +endpoint render useful approval and audit records and apply consent policy +consistently to convention and native addresses. They remain resolver-declared +assertions, not authenticated subject identity or authorization. + +If grants must instead follow the application that invoked SecretSpec, a later +capability must define a cryptographic delegation that is: + +- issued from an identity authenticated on the client-to-resolver transport; +- signed by an issuer trusted by the provider or provider-owned agent; +- audience-bound to that provider endpoint or agent; +- scoped to operations and addresses; +- short-lived and bound to a nonce or request; +- resistant to replay, substitution, and downgrade. + +Version 1 contains no caller-identity field and no delegation capability. +Adding unverified identity metadata before that design exists would create a +confused-deputy boundary. + +## Compatibility rules + +- Wire and application protocol versions are integers. A breaking change uses + a new integer version. +- Optional behavior is introduced through named capabilities. A sender must + not use a method or field gated by a capability the server did not + advertise. +- Unknown capabilities are ignored. Unknown methods receive + `method_not_found`. Unknown parameters are rejected so misspellings do not + weaken a security decision. +- The C library/ABI, an SDK package, the Rust handler crate, resolver binary, and + provider endpoint each have their own product versions. None of those version + strings replaces protocol negotiation. +- No request that might have reached a handler is automatically replayed after + a disconnect. A new connection is a new session. + +## Reserved for dynamic secrets + +Dynamic secrets are issued on demand, have a backend-bounded lifetime, and may +be renewed or revoked rather than merely read. Version 1 carries a +provider-reported secret expiry on reads starting with SecretSpec 0.20. It does +not model issuance, renewal, an explicit credential-lease handle, revocation, +or feedback from an application whose use of a value failed. Those lifecycle +operations remain reserved here because the wire protocol's compatibility +rules are decided once, and an extension point that was not reserved before the +protocol shipped costs a new protocol version rather than a capability. + +The design constraint is that every item below lands additively, under the +[forward-compatibility rules](/reference/ipc-wire#forward-compatibility): + +| What dynamic issuance needs | How it lands on `secretspec.resolver/1` | +| --- | --- | +| A new provenance value on a resolved result | `source` gains a value. Receivers already decode an unknown `source` rather than failing, so no version change | +| A "this name needs a live session" failure | A new error kind and a reserved code. Receivers already decode an unknown kind as a failure | +| Secret validity expiry | Already represented by `expires_at_unix_ms`; cache freshness is separately represented by `refresh_at_unix_ms` | +| Lifecycle facts such as renewable/revocable state and a credential-lease handle | New result members, sent only to a client that advertised the matching capability | +| A caller asking for a value with a minimum lifetime left | A new capability-gated request member. Deliberately absent from version 1 even though provider reads can report expiry: minimum lifetime affects provider selection and issuance policy, so silently adding it to an existing strict request would be unsafe | +| Atomic multi-output resolution | A new capability-gated method. One issuance can feed several declared names, so they must be read as one consistent set rather than name by name | +| Session health, and a warning that a lease is nearing its bound | A capability-gated notification, or a callback in the direction `client.prompt` established | +| Replacement, where reissued bytes differ and the consumer must adopt them | A callback, since adoption needs an answer from the consumer rather than a fire-and-forget signal | + +Three constraints follow from this, and version 1 already honors them: + +- **A session is the lifecycle unit.** A dynamic lease outlives no connection: + it belongs to the session that issued it, and disconnect is already the + release path for every session-owned resource. This is why the transport binds + one session to one connection with no reconnection or resumption. +- **"Lease" is two different things.** `resolver.get` returns a `path_lease_id`, + a local lifetime handle over a resolver-owned file. A dynamic credential lease + is a backend resource with issuance, renewal, and revocation. They are + unrelated, which is why the existing field is named for the path it releases + rather than for leases in general; a credential lease gets its own distinctly + named members. +- **A snapshot is not whole-profile access.** Version 1's non-goal is arbitrary + access for a consumer that asked for one name. A future atomic snapshot is + bounded by the profile and scope the session already fixed at initialization, + so it does not reach past what this session was opened for. + +Nothing here reserves authority the protocol does not already grant. In +particular, a dynamic provider still attests its own principal, and no +caller-supplied field becomes an authorization input; see the principal and +delegation decision above. + +## Non-goals for version 1 + +- Remote network RPC, TLS, or service-to-service authentication. +- Client-to-daemon or client-to-remote-store forwarding of secret authority. +- Exposing provider storage, caches, encryption, databases, or synchronization + engines through the resolution protocol. +- Replacing all existing embedded language SDKs. +- Arbitrary whole-profile or arbitrary-provider access for consumers that ask + for one declared name. +- Authenticated delegation of the original application identity. +- Exactly-once execution of provider side effects. The contract guarantees one + terminal response, not transactional rollback after cancellation or a lost + connection. diff --git a/docs/src/content/docs/reference/ipc-wire.md b/docs/src/content/docs/reference/ipc-wire.md new file mode 100644 index 000000000..c30479cbd --- /dev/null +++ b/docs/src/content/docs/reference/ipc-wire.md @@ -0,0 +1,578 @@ +--- +title: IPC wire protocol +description: Shared framing, negotiation, cancellation, errors, and lifecycle for SecretSpec IPC +--- + +This document defines the transport-neutral wire contract shared by the +[Secret Resolution Protocol](/reference/resolver-protocol) and the +[Secret Provider Protocol](/reference/provider-protocol). + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, +and **MAY** are interpreted as described by +[BCP 14](https://www.rfc-editor.org/rfc/rfc8174). + +:::caution[Version compatibility] +SecretSpec IPC wire protocol version 1 is available starting with SecretSpec +0.20. +::: + +## Quick reference + +| Property | Version 1 | +| --- | --- | +| RPC envelope | [JSON-RPC 2.0](https://www.jsonrpc.org/specification) | +| JSON encoding | UTF-8 JSON as defined by [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259) | +| Frame | One UTF-8 JSON object followed by LF (bounded NDJSON) | +| Absolute frame limit | 1,048,576 JSON bytes, excluding the terminating LF | +| Request IDs | Positive JSON integers from 1 through 9,007,199,254,740,991; strictly increasing within one direction of a session | +| Concurrency | Negotiated, at least 1 and at most 32 in-flight requests in version 1 | +| Deadline | Mandatory absolute Unix time in milliseconds on every request | +| Discovery | Side-effect-free `rpc.discover` before or after initialization (0.20+) | +| Cancellation | `rpc.cancel` notification naming the original request ID | +| Callbacks | `client.`-prefixed requests an endpoint sends to its client, only when advertised (0.20+) | +| Shutdown | `rpc.shutdown`, then EOF and bounded process termination | + +## Framing + +Each message is encoded as one JSON object followed by one LF byte: + +```text +{"jsonrpc":"2.0","id":1,...}\n +``` + +Partial pipe reads are normal. A receiver accumulates bytes until LF, and MUST +close the transport if LF has not arrived before `max_frame_bytes` JSON bytes. + +- An empty line is a protocol violation. +- LF is the only delimiter; CR and literal line breaks in the JSON bytes are + rejected. JSON string newlines are encoded as `\n` and remain one line. +- EOF between lines is a clean disconnect; EOF after any JSON byte is a + truncated-frame protocol violation. +- The payload MUST be one JSON object. JSON-RPC batch arrays are not supported. +- A payload MUST be valid UTF-8 and MUST NOT contain duplicate object keys. +- Implementations SHOULD reject JSON nested more than 64 containers deep. +- A writer MUST serialize a complete frame. Concurrent writers must use one + frame-level lock or a single writer task so bytes from two messages cannot + interleave. + +Discovery (0.20+) and initialization use the same absolute limit. +Initialization negotiates a possibly smaller `max_frame_bytes` for the rest of +the session. Until the successful initialization response is committed, that +1 MiB ceiling applies in both directions, including callback requests and +responses. + +## JSON-RPC profile + +Version 1 uses the JSON-RPC string `"2.0"`. Requests and responses use the +standard `method`, `params`, `result`, and `error` members. +`rpc.discover` (0.20+), `rpc.initialize`, `rpc.cancel`, and `rpc.shutdown` are +the RPC-internal extensions defined by this profile. Application methods a +client calls on an endpoint use the `resolver.` or `provider.` prefix; the +[callbacks](#callbacks) an endpoint calls on its client use the `client.` +prefix. + +Request IDs are positive integers no larger than JavaScript's exactly +representable integer limit. A sender MUST choose IDs in strictly increasing +order for every request it makes. A receiver stores only the last ID it saw in +that direction and rejects an ID less than or equal to it. Notifications omit `id`; version 1 defines only the +`rpc.cancel` notification. + +Each direction has its own ID space. A server that calls back on the client +(see [callbacks](#callbacks)) allocates its request IDs independently, so the +same integer may be in flight in both directions at once and means a different +request in each. A response is matched against the requests the receiving side +itself sent; a response naming an ID that side never used is a protocol +violation, not an application error. + +A response repeats the corresponding request ID. An error response uses a null +ID only when a parse or invalid-request failure made the supplied ID unavailable +or unusable; null is never a valid request ID. + +The receiver MUST reject requests with: + +- a string, fractional, zero, negative, null, or out-of-range request ID; +- an ID less than or equal to the last request ID received in that direction; +- unknown top-level members; +- unknown members in a method's `params` object; +- a request sent before successful initialization, other than `rpc.discover` + (0.20+) or `rpc.initialize`; +- a second `rpc.initialize` request. + +A notification has no response channel. Its envelope is nevertheless closed: +it contains exactly `jsonrpc`, `method`, and an object-valued `params` member. +Unknown notification methods, cancellation for an unknown or completed ID, and +malformed `rpc.cancel` parameters are ignored (and may produce redacted +diagnostics). An unknown top-level notification member, malformed JSON, +duplicate keys, and an oversize or unterminated frame remain transport-fatal. + +Unknown advertised capabilities are ignored. Strict parameter parsing is +intentional: extensions use capabilities and protocol versions rather than +silently ignored security-sensitive fields. + +This has a standing consequence for anyone extending a method. Protocols that +discard unknown fields let a sender add one and degrade quietly against an older +peer; that leniency is how a value meant for one destination gets accepted by +another. Here the receiver rejects the request instead, so **adding a field to +an existing method is a breaking change unless a capability gates it**. An +optional field is only optional to a peer that advertised the capability naming +it, and a sender must not write the field to a peer that did not. Reviewers +should read a new `params` member as a new capability until shown otherwise. + +## Runtime discovery (0.20+) + +`rpc.discover` returns the endpoint's self-contained +[OpenRPC](https://spec.open-rpc.org/) document without initializing application +state. It is the only request other than `rpc.initialize` that a server accepts +before initialization. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "rpc.discover", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": {} +} +``` + +The result is the OpenRPC document itself. Its `x-secretspec` extension carries +the endpoint-specific facts that do not belong to the application interface: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "openrpc": "1.3.2", + "info": { + "title": "SecretSpec Secret Resolution Protocol", + "version": "1" + }, + "methods": [ + { + "name": "rpc.discover", + "params": [ + { "name": "params", "required": true, "schema": { "type": "object" } } + ], + "result": { + "name": "OpenRPC document", + "schema": { "type": "object" } + } + } + ], + "components": { + "schemas": { "...": "..." } + }, + "x-secretspec": { + "protocol": "secretspec.resolver", + "versions": [1], + "server": { + "name": "secretspec-resolver", + "version": "0.20.0" + }, + "methods": ["resolver.get", "resolver.release"], + "absolute_max_frame_bytes": 1048576 + } + } +} +``` + +Rules: + +- Discovery MUST NOT load a manifest, initialize a provider, obtain a + credential, send a callback, or create application session state. +- The returned OpenRPC document MUST include all referenced schemas. A caller + does not need network access, an installed schema directory, or a source + checkout to interpret it. +- `x-secretspec.methods` lists the application methods this endpoint can + advertise. It is descriptive only; a client still uses the `methods` selected + by `rpc.initialize` as the authority for application calls. +- Discovery does not select a protocol version, negotiate limits, or make the + endpoint ready. After replying, an uninitialized endpoint continues waiting + for `rpc.initialize` or EOF. +- Discovery may be repeated with fresh, increasing request IDs. After + initialization its response is subject to the negotiated frame limit; before + initialization it is subject to the absolute frame limit. +- Invalid parameters receive `invalid_params`; an expired deadline receives + `deadline_exceeded`. Either response leaves the session usable. + +## Initialization and capabilities + +`rpc.initialize` MUST be the first request that creates or addresses application +state. It MAY be preceded only by `rpc.discover` (0.20+) requests. Clients that +do not need discovery send initialization as request ID 1. Its envelope carries +the startup deadline; clients commonly choose 5 seconds, and servers may enforce +a shorter local startup bound. Its `application` member is defined by the +selected application protocol. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "rpc.initialize", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "protocol": "secretspec.resolver", + "versions": [1], + "client": { "name": "nix", "version": "2.34.0" }, + "limits": { + "max_frame_bytes": 1048576, + "max_in_flight": 8 + }, + "client_methods": [], + "application": {} + } +} +``` + +`client_methods` (0.20+) lists the [callbacks](#callbacks) this client can +answer. It is optional and defaults to empty, which is what a client that +answers none sends, so omitting it is the same as listing nothing. + +The server selects the highest version it supports from `versions`, advertises +its application `methods` separately from optional boolean `capabilities`, and selects limits no larger than either +implementation's limits. `rpc.cancel` is fixed wire behavior rather than an +application capability. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocol": "secretspec.resolver", + "version": 1, + "server": { "name": "secretspec-resolver", "version": "0.20.0" }, + "methods": ["resolver.get", "resolver.release"], + "capabilities": {}, + "limits": { + "max_frame_bytes": 1048576, + "max_in_flight": 8 + }, + "application": {} + } +} +``` + +Rules: + +- `versions` MUST contain distinct positive integers. +- `max_frame_bytes` MUST be between 4,096 and 1,048,576. +- `max_in_flight` MUST be between 1 and 32. +- `client.name`, `client.version`, `server.name`, and `server.version` are + diagnostic product identifiers, not authorization inputs. +- The returned protocol name MUST exactly equal the requested name. +- Application methods and fields gated by a capability MUST NOT be used unless + the server advertised it. +- Unsupported protocols or versions return `unsupported_version`; the server + then closes the connection. + +Discovery and initialization requests are constrained by the pre-negotiation +limits of one frame and one in-flight request. + +Initialization has one terminal failed state. Before readiness, the only valid +requests are discovery before the initial `rpc.initialize` and that +initialization request; responses may only answer callbacks raised by the +initialize. Structurally valid notifications retain their normal semantics, so +cancellation of the initialize and unknown notification methods are accepted. +An application request or another request while initialization is active +receives at most one value-free `invalid_request` response and the connection +closes. Invalid initialization parameters receive `invalid_params` and close; +an unsupported protocol or version receives `unsupported_version` and closes. +A second initialization after readiness receives `invalid_request` and closes. +A response that does not match an initialization callback has no response +channel and closes the connection immediately. No application request is +processed after initialization fails. + +## Application requests + +Every request contains `_meta.deadline_unix_ms`. It is an unsigned integer +containing milliseconds since the Unix epoch. Notifications do not carry a +deadline. + +A deadline more than 300 seconds in the future is clamped to that horizon +rather than rejected. Senders apply the clamp before writing the value, so a +receiver never enforces a longer deadline than the sender waits for. Without a +bound, one request could hold an in-flight slot for the life of the process and +no timeout would reclaim it. + +A deadline that has already passed when the caller supplies it is reported as +`deadline_exceeded` and nothing is written, so the session stays usable. It is +not an argument error: the same call a millisecond earlier succeeds, and a +caller that computed its deadline just before a slow code path should not see +the failure change kind because of where the boundary fell. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "resolver.get", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "name": "FORGE_TOKEN", + "representation": "value", + "purpose": { + "consumer": "nix", + "operation": "fetch", + "host": "github.com", + "path": "/acme/project" + } + } +} +``` + +On receipt, the server MUST compare the value with its wall clock and convert +the remaining interval into an internal monotonic deadline. It MUST NOT start +an already expired request. Queueing, provider access, interaction, response +serialization, and frame writing all consume the deadline. + +The server MUST continue reading frames while handlers run so cancellation and +other requests can be accepted. It MUST enforce the negotiated in-flight limit +without allocating unbounded queues. A request over the limit receives an +`unavailable` error with `retryable: true`. + +## Callbacks + +:::caution[Version compatibility] +Server-initiated requests are available starting with SecretSpec 0.20. +::: + +Almost every request travels from client to server. One case does not, and it +exists because the side that discovers a value is missing is never the side that +can obtain it: a stdio endpoint has no terminal, since its stdin and stdout are +the protocol. Rather than let an endpoint touch the terminal behind the +protocol's back, it asks its client, on the same connection, and the client +decides how to obtain the answer. + +Callback methods use the `client.` prefix and are listed by the client in +`client_methods` during initialization. The rules are deliberately the +mirror image of an ordinary request, with one addition: + +- A server MUST NOT send a method the client did not advertise. A client that + advertised nothing is never called back, and an endpoint that needed to ask + must fail the originating request instead of waiting. +- A client MUST answer every callback it accepted with exactly one terminal + response, under the same rules an endpoint follows. +- A callback's `_meta.parent_request_id` MUST name the active request it serves; + its deadline MUST NOT be later than that request's deadline, and + cancelling that request cancels the callback. A missing, unknown, terminal, + or otherwise invalid parent association is a protocol violation and closes + the connection immediately. +- Both sides MUST keep reading while a callback is outstanding. The connection + that carries the callback also carries the response the client is waiting for, + so a client that blocks its reader to answer deadlocks the session until the + deadline elapses. +- In-flight counts are directional. The initialization request occupies the + client-to-server space independently of callbacks in the server-to-client + space. Until its successful response commits, at most one callback may be + outstanding. The negotiated `max_in_flight` bounds callbacks after readiness. + A server MUST NOT send beyond the active bound, and a client closes when a + peer violates it. +- Callbacks carry secrets in the same way responses do, and are subject to the + same logging and redaction rules. + +An implementation that answers no callbacks needs none of this: it advertises +nothing, never receives a request, and continues to treat an inbound request as +the protocol violation it is. + +Version 1 defines two callbacks (both available in SecretSpec 0.20+): + +- `client.prompt` lets a resolver ask its launching client for one declared + secret value; +- `client.credential` lets an external provider request one semantic, + URI-scoped provider credential. Its closed request and result shapes are + specified by the [provider protocol](/reference/provider-protocol/#credential-brokerage-020). + +The server read loop must remain live during `rpc.initialize` as well as during +ordinary methods. A credential callback commonly occurs inside provider +initialization; waiting for initialization to return before consuming the +callback response deadlocks both peers. + +## Cancellation and terminal responses + +The client cancels an in-flight request with a notification: + +```json +{ + "jsonrpc": "2.0", + "method": "rpc.cancel", + "params": { "id": 2 } +} +``` + +Cancellation has these race rules: + +1. The server associates a cancellation token with every accepted request. +2. A cancellation for an unknown or already terminal ID is ignored. +3. If cancellation wins before the terminal response is committed to the + writer, the original request receives one `cancelled` error. +4. If the terminal response was already committed, it remains the response and + cancellation has no effect. +5. The notification itself never receives a response. + +An **accepted application request** is a complete, valid JSON-RPC request with a +fresh ID that has passed initialization, capability, parameter, and +in-flight-limit checks. Every accepted application or `rpc.discover` (0.20+) +request MUST produce exactly one terminal `result` or `error` response unless +the transport disconnects before it can be written. +The writer owns the atomic terminal-state transition so a handler, deadline, +and cancellation race cannot emit two responses. + +Cancellation and deadlines stop waiting; they do not promise rollback. A +provider mutation may have reached its backend even when the caller receives +`cancelled`, `deadline_exceeded`, or loses the connection. Clients MUST NOT +automatically retry such a request. Servers should propagate cancellation to +the underlying operation and must discard any late result. + +## Errors + +All failures use the JSON-RPC error object. `message` is a short stable summary. +Machine handling uses `data.kind`. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32005, + "message": "permission denied", + "data": { + "kind": "permission_denied", + "retryable": false + } + } +} +``` + +Standard JSON-RPC errors retain their standard codes: + +| Code | Kind | +| --- | --- | +| `-32700` | `parse_error` | +| `-32600` | `invalid_request` | +| `-32601` | `method_not_found` | +| `-32602` | `invalid_params` | +| `-32603` | `internal` | + +SecretSpec reserves these server-error codes for both protocols: + +| Code | Kind | Retryable by default | Meaning | +| --- | --- | --- | --- | +| `-32000` | `unsupported_version` | No | No common application protocol version | +| `-32001` | `capability_required` | No | The requested operation was not advertised | +| `-32002` | `deadline_exceeded` | No | The request deadline elapsed | +| `-32003` | `cancelled` | No | The caller cancelled the request | +| `-32004` | `unavailable` | Yes | Endpoint, dependency, or capacity temporarily unavailable | +| `-32005` | `permission_denied` | No | The authenticated principal is not authorized | +| `-32006` | `interaction_required` | No | User interaction is required but unavailable | +| `-32007` | `conflict` | No | State or destructive-operation identity conflict | +| `-32008` | `operation_failed` | No | Stable catch-all for a provider or resolver failure | +| `-32009` | `message_too_large` | No | A result cannot fit the negotiated frame limit | +| `-32010` | `representation_mismatch` | No | The requested value/path representation does not match the declaration | + +`data` MUST contain `kind` and `retryable`. It may contain `retry_after_ms` only +for `unavailable`. Starting in 0.20, an `interaction_required` error may instead +contain an `interaction` reference with a bounded opaque `id`, an interaction +`kind`, and a nullable absolute expiry. Version 1 defines the `authorization` +interaction kind. The reference correlates provider-owned interaction; it is +neither a bearer credential nor permission to retry automatically. A receiver +MUST use `kind`, not parse `message`. + +Codes `-32011` and below in the SecretSpec range are reserved for later +revisions. A receiver MUST accept an error whose code and kind it does not know +and report it as a failure it cannot name; see +[forward compatibility](#forward-compatibility). + +Errors are non-secret. Apart from the bounded opaque interaction ID and expiry, +`message` and `data` MUST NOT contain secret values, provider credentials, full +provider URIs, addresses, secret names, manifest contents, file paths, +executable paths, delegation material, or backend error bodies. Detailed +diagnostics belong in a separately protected local diagnostic channel and must +be redacted before display. A provider endpoint maps arbitrary backend failures +to this stable set; it must not forward their text blindly. + +## Forward compatibility + +Version 1 is strict on the way in and tolerant on the way out. Those are not in +tension: a misspelled parameter must never weaken a security decision, while a +descriptive value a receiver does not recognize must never take down a session. +A protocol that is strict in both directions cannot grow at all, because the +first addition breaks every deployed peer and the only remaining move is a new +protocol version. + +The rules an implementation must follow to keep version 1 extensible: + +1. **Enumerations are closed for senders and open for receivers.** A sender + emits only the values this document defines. A receiver that meets a value it + does not know MUST decode it as an explicit unrecognized value rather than + failing the frame. This applies to `error.data.kind` and to descriptive + result enumerations such as the resolver's `source`. +2. **An unrecognized error is a failure.** A receiver treats an unknown + `kind` as a non-retryable failure unless `retryable` is true, and never as a + success or as a reason to retry blindly. +3. **A defined code must carry its defined kind.** Only a code a receiver has + never seen may arrive with a kind it has never seen. A known code paired with + an unknown kind is a peer defect and is still rejected, so tolerance never + becomes a way to smuggle one error past a receiver as another. +4. **Requests are strict and results are tolerant.** Request objects and + notification envelopes reject unknown members. Receivers ignore unknown + members in response and result objects, so descriptive output can grow + without a new capability. +5. **New methods, notifications, and callbacks are capability-gated.** A + receiver ignores an unknown notification method because it has no response + channel. A sender nevertheless sends a later notification only when the + receiving side advertised its defining capability. Server capabilities gate + client-to-server notifications; a future server-to-client notification needs + an explicitly negotiated client capability or a later protocol version. +6. **Codes and names are reserved, not recycled.** A server-error code, a method + name, or a capability name that this document has assigned is never reused + for a different meaning in the same protocol version. + +None of this weakens strict request parsing. An endpoint still rejects an +unknown member of a `params` object, an unknown method, and a request that used +a capability it did not advertise. + +## Shutdown and disconnect + +The client requests orderly shutdown after all ordinary calls have completed: + +```json +{ + "jsonrpc": "2.0", + "id": 99, + "method": "rpc.shutdown", + "_meta": { "deadline_unix_ms": 1786766410000 }, + "params": {} +} +``` + +The server enters `DRAINING`: it rejects new application requests, lets accepted +work finish, and cancels what remains only when the shutdown deadline expires. +It then releases session resources and responds with an empty result: + +```json +{ "jsonrpc": "2.0", "id": 99, "result": {} } +``` + +The client then closes its write pipe. The child SHOULD exit within the +remaining shutdown deadline, capped at 5 seconds. After that grace period the +launcher terminates it with the platform process API and waits for it to exit. + +EOF or process exit without `rpc.shutdown` performs the same resource cleanup. +The server cannot send pending responses after a disconnect, but it must cancel +their handlers and prevent late handlers from retaining leases or secret +buffers. + +## Secret handling + +- Neither side may log complete frames, request parameters, response results, + environment snapshots, or provider stderr at normal or debug levels. +- Frame buffers that may contain values or credentials SHOULD use zeroizing + storage and be dropped promptly. This is best-effort in garbage-collected + languages and does not replace process isolation. +- Crash reports, tracing fields, and panic messages must contain method names + and error kinds only. +- Stderr is a diagnostic channel, not part of the protocol. Endpoints MUST NOT + write secret-bearing data to it. A host must treat stderr as sensitive until + it has applied an explicit redaction policy. +- Backpressure must be bounded on stdin, stdout, stderr capture, handler queues, + and completed responses waiting for the writer. diff --git a/docs/src/content/docs/reference/provider-credentials.mdx b/docs/src/content/docs/reference/provider-credentials.mdx index 56f81f57f..429112418 100644 --- a/docs/src/content/docs/reference/provider-credentials.mdx +++ b/docs/src/content/docs/reference/provider-credentials.mdx @@ -10,10 +10,16 @@ from another SecretSpec provider. They are supported in SecretSpec 0.15 and later. The table below is the exhaustive reference for accepted semantic credential -names. An explicitly configured provider credential takes precedence over its +names for providers compiled into SecretSpec. An explicitly configured provider credential takes precedence over its environment fallback. When more than one fallback is listed, SecretSpec checks them from left to right. +External providers negotiate their URI-specific semantic names through the +provider protocol in SecretSpec 0.20+. They do not appear in this compile-time +catalog. Their alias may omit `credentials`; SecretSpec then uses its +provider-private operating-system keyring namespace. A configured mapping is a +lazy source override for the name the endpoint requests. + See [Provider credentials](/concepts/providers/#provider-credentials) for diff --git a/docs/src/content/docs/reference/provider-protocol.md b/docs/src/content/docs/reference/provider-protocol.md new file mode 100644 index 000000000..4f94f6b56 --- /dev/null +++ b/docs/src/content/docs/reference/provider-protocol.md @@ -0,0 +1,878 @@ +--- +title: Secret Provider Protocol +description: Version 1 IPC contract for out-of-tree SecretSpec provider endpoints +--- + +The Secret Provider Protocol is the southbound IPC boundary between +SecretSpec's resolver and an out-of-tree provider endpoint. The endpoint +implements provider naming and storage operations while its database, +encryption, agents, hardware keys, grants, and remote APIs remain private. + +It uses the [shared IPC wire protocol](/reference/ipc-wire) with application +protocol name `secretspec.provider` and version `1`. + +:::caution[Version compatibility] +External provider discovery and Secret Provider Protocol version 1 are +available starting with SecretSpec 0.20. This specification replaces the +direction explored by the closed, unmerged +[provider protocol PR #98](https://github.com/cachix/secretspec/pull/98). +::: + +## Provider model + +A session is bound to exactly one configured provider URI. The endpoint may +retain connections and authentication state for that session, but must not +serve another URI or another SecretSpec client through the same process. + +The protocol uses SecretSpec's two canonical address forms: + +- a convention address: `{project, profile, key}`, which the provider compiles + into its native namespace; +- a native address: the coordinates from a declaration's `ref`. + +Routing is not part of the address. SecretSpec chooses the provider instance, +fallback chain, authoritative provider, and cache before making the request. +The endpoint sees only the operation for its bound provider. + +Version 1 covers naming, reads with optional secret-validity expiry, presence +checks, writes, expiring writes, idempotent deletion, bounded cache clearing, +mutation preflight, batch reads, write-target descriptions, and declaration +reflection. Every optional method is capability-gated. + +## Discovery and registration + +A provider URI scheme matches `^[a-z][a-z0-9-]*$`. A scheme compiled into the +SecretSpec binary always selects that in-tree provider; an external +registration must not shadow it. Every other scheme is resolved in this order: + +1. an endpoint supplied directly through the embedding API; +2. a user claim named `.secretspec.json`; +3. a system claim named `.secretspec.json`; +4. a `PATH` executable named `secretspec-provider-` (or + `secretspec-provider-.exe` on Windows), only when PATH discovery is + explicitly allowed. + +The public provider claim has the same format on every platform: + +```json +{ + "executable": "/absolute/path/to/secretspec-provider-example" +} +``` + +`executable` MUST be absolute. SecretSpec invokes it directly as ` +provider`, without a shell. The filename supplies the provider scheme. A claim +must not contain a provider URI, credential, secret address, or secret value. +The document has no schema version: discovery only establishes the executable, +while the launched IPC session negotiates the protocol version. Future claim +fields are additive and older clients ignore fields they do not understand. + +Default registration directories are: + +| Platform | User | System | +| --- | --- | --- | +| Linux | `$XDG_CONFIG_HOME/secretspec/providers.d`, falling back to `$HOME/.config/secretspec/providers.d` | `/etc/secretspec/providers.d` | +| macOS | `$HOME/Library/Application Support/SecretSpec/providers.d` | `/Library/Application Support/SecretSpec/providers.d` | +| Windows | `%APPDATA%\SecretSpec\providers.d` | `%PROGRAMDATA%\SecretSpec\providers.d` | + +The loader MUST validate the claim filename against `scheme`, resolve the +executable to an absolute canonical path, and check that claim and executable +ownership/ACLs are appropriate for the trust domain. A privileged resolver MUST +disable PATH discovery. A manifest may select a registered scheme and URI but +must never supply an executable path or launch arguments. + +On Unix the ownership and permission checks apply to every directory above the +endpoint, not only its immediate parent: a single writable ancestor lets an +attacker replace a component with a symlink to any executable that satisfies +the checks below it. Because the checks run over the resolved path, a symlinked +component is validated as the chain it points at, so the common cases where a +symlink is how software is installed — a Nix store path, macOS's `/var` — keep +working. A world-writable ancestor is trusted only when it is sticky, which +stops anyone but the owner replacing an entry inside it. + +Windows applies the same full canonical-ancestor rule through ACL validation. +The executable and every directory above it must deny untrusted principals the +rights that permit replacement, including directory write access and +`FILE_DELETE_CHILD`; validating only the executable or its immediate parent is +not sufficient. The walk follows the canonical path so junctions and other +reparse points are checked where they resolve. + +Once resolved, the executable identity is fixed for the session. Replacing a +registration file or changing PATH does not replace a running endpoint. + +## Starting and initializing an endpoint + +The host launches one child with private stdin/stdout pipes and normally sends +`rpc.initialize` immediately. Inspection tooling may first call the +side-effect-free `rpc.discover` method (0.20+) without initializing the provider. +The full initialization request follows the +[wire protocol](/reference/ipc-wire#initialization-and-capabilities). +Its `application` member is: + +```json +{ + "scheme": "example", + "uri": "example://default", + "context": { + "project": "payments", + "profile": "production", + "base_dir": "/absolute/project/directory", + "reason": "deploy production api", + "requested_authorization_duration_ms": 28800000 + } +} +``` + +Rules: + +- `scheme` is the validated URI scheme used for discovery. +- `uri` is the original configured provider URI. It is sensitive input and is + never logged or echoed because it may contain credentials. +- `context` (0.20+) carries the resolver-declared project, active profile, absolute + base directory, access reason, and optional requested authorization duration. + The original four members are nullable. Project and + profile are context for approval, policy, and audit surfaces, including for + native addresses that do not carry convention components. They are + assertions, not authenticated application identity or delegation. +- Convention-address project and profile components still name each operation; + they may differ from the session context when resolution falls back or a + low-level client intentionally addresses another namespace. An endpoint must + scope the requested resource from the address and use session context only + where the native address carries no corresponding component. +- `context.base_dir` is the absolute directory against which provider-relative + paths are resolved, or null when the provider has no project base directory. +- `context.reason` is the session-wide access reason or null. An endpoint + needing a different reason must use another session. +- `context.requested_authorization_duration_ms`, when present, is a positive + app-requested default for an approval surface. It is not an authorization or + an upper bound: the endpoint and approving user choose the actual lifetime. +- The endpoint MUST reject a URI whose scheme differs from `scheme`. + +The provider does not receive `config_file` and does not reread arbitrary +SecretSpec manifests. Provider-specific configuration belongs in the provider +URI, registered endpoint configuration, or a future explicitly typed +capability. This keeps the provider boundary deterministic and prevents an +endpoint from acquiring declarations outside the operation it was sent. + +### Interaction model + +Protocol stdin and stdout are exclusively reserved for framed IPC messages. +An endpoint MUST NOT read unframed user input from stdin, write prompts or +instructions to stdout, or rely on stderr as a user-interaction channel. + +An endpoint MAY complete authentication through a provider-owned channel that +is independent of the protocol streams, such as an existing desktop agent, +browser session, hardware confirmation prompt, or operating-system credential +UI. That interaction consumes the request deadline. + +If the operation cannot complete without unavailable user interaction, the +endpoint MUST return `interaction_required`. It MUST NOT include backend error +text or provider-supplied remediation instructions in the error response. + +An `interaction_required` error may carry an opaque structured interaction +reference (0.20+). Its `id` correlates the failed operation with a +provider-owned CLI, agent, or notification, and is not authorization material. +Version 1 defines the `authorization` interaction kind. The optional expiry +bounds how long the provider will retain that pending interaction. The +reference contains no provider-authored message, command, address, secret name, +path, or credential; the host renders trusted local guidance for the selected +provider scheme. + +The host MUST NOT automatically replay the failed request. After the user +completes authentication out of band, the caller MAY explicitly issue a new +request with a fresh request ID. Configuration or credentials that changed +require a newly initialized provider session. + +A host or CLI MAY display locally authored remediation selected from the +trusted provider scheme. Such instructions are product behavior and are not +provider-protocol data. + +A successful initialization response includes this `application` object: + +```json +{ + "provider": { + "name": "example", + "display_uri": "example://default", + "supported_coordinates": ["field"], + "generated_value_persistence": "persist", + "prompted_value_persistence": "persist", + "storage_identity": "example://default", + "entry_container_identity": "example://default", + "physical_store_path": null + } +} +``` + +Metadata rules: + +- The top-level initialization result `capabilities` list advertises the + provider methods supported by the endpoint. It is the only + operation-capability list; the `provider` metadata object does not duplicate + it. +- `name` is lowercase and matches the registered provider scheme unless the + registration explicitly aliases a protocol-compatible provider. +- `display_uri`, `storage_identity`, and `entry_container_identity` MUST be + credential-free and MUST NOT contain secret names or values. +- `supported_coordinates` lists any accepted native coordinate beyond the + required `item`. Version 1 names are `field`, `vault`, `section`, and + `version`. +- persistence values are `persist` or `ephemeral` and map to the corresponding + `Provider` trait methods. They are pure capability metadata. +- `physical_store_path` is an absolute path or null. The host treats it as + provider-supplied identity metadata and applies its ordinary same-file rules. +- `provider.resolve_address` is mandatory. At least one of `provider.get`, + `provider.exists`, or `provider.set` MUST be present. + +The endpoint is ready when this response has been validated. Authentication or +unlock that requires I/O may stay lazy until the first operation. + +### Credential brokerage (0.20+) + +Credential requirements are negotiated by the endpoint; they are not part of +the public provider claim or the initialization application. An endpoint knows +which authentication path a particular URI selects, so it requests only the +credentials that path actually needs with `client.credential`. The client +advertises this callback in `client_methods` and may answer it while +`rpc.initialize` is still active or during a later token refresh. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "client.credential", + "params": { + "name": "access_token", + "scope": "example://account/team-a", + "required": true + }, + "_meta": { + "deadline_unix_ms": 1760000000000, + "parent_request_id": 1 + } +} +``` + +`name` is a lowercase semantic identifier matching +`^[a-z][a-z0-9_]*$` and is at most 256 bytes. `scope` is a stable, +credential-free account or store identity chosen from the configured URI; it +is non-empty and at most 4,096 bytes. `required` tells an interactive client +that declining will prevent this authentication path, but does not turn an +ordinary broker miss into a transport error. + +The result is either: + +```json +{ "status": "found", "value": "secret value" } +``` + +or: + +```json +{ "status": "missing" } +``` + +The host binds every lookup to the already discovered provider scheme, so an +endpoint cannot request another provider's credentials by changing `scope`. +SecretSpec accepts at most 64 distinct `(scope, name)` requests in one session. +It resolves a name in this order: + +1. a matching `credentials` source on the selected provider alias, fetched + lazily only after the endpoint requests it; +2. SecretSpec's provider-private operating-system keyring namespace, keyed by + provider scheme, a hash of `scope`, and `name`; +3. `missing`. + +The endpoint remains free to try its native environment, workload identity, +desktop agent, or browser authentication before or after a broker miss. This +makes provider configuration optional: `credentials = { ... }` is an explicit +storage override, not a second declaration of the endpoint's credential +vocabulary. `secretspec config provider login ` starts the endpoint, +answers the credentials it requests, and stores those answers in the private +keyring namespace when the alias has no explicit mappings. An endpoint that +wants this login flow to provision its broker-managed authentication MUST make +those URI-selected requests during initialization, even if it defers validating +the returned values or contacting its backend until the first operation. + +A client that does not advertise `client.credential` behaves like an empty +broker. An endpoint MUST handle that as `missing`; it MUST NOT wait for a +callback the client did not advertise. Credential values travel only in framed +requests and responses on the inherited private pipe, never in argv or protocol +environment variables. + +Rust endpoints use the typed helper rather than constructing a reverse +JSON-RPC request directly: + +```rust +use secretspec_ipc::protocol::callback::CredentialParams; +use secretspec_ipc::provider::request_credential; + +let token = request_credential( + context, + CredentialParams { + name: "access_token".into(), + scope: account_identity, + required: false, + }, +).await?; +``` + +`request_credential` returns `None` both for a broker miss and when the client +did not advertise the callback. The returned `SecretValue` zeroizes its owned +buffer on drop. + +## Address schema + +Every operation uses one of these closed tagged objects. + +Convention address: + +```json +{ + "kind": "convention", + "project": "payments", + "profile": "production", + "key": "DATABASE_PASSWORD" +} +``` + +Native address: + +```json +{ + "kind": "native", + "coordinates": { + "item": "databases/payments", + "field": "password", + "vault": "Production", + "section": null, + "version": null + } +} +``` + +All strings are UTF-8 and individually limited to 4,096 bytes. `item` is +required and must be non-empty. Optional coordinates may be omitted or null; +the two forms are equivalent. Unknown coordinates are rejected. An endpoint +MUST reject a present coordinate it did not advertise in +`supported_coordinates`. + +The endpoint must use one address resolution path for `get`, `exists`, `set`, +`set_expiring`, `delete`, preflight, and identity comparisons. It must never +guess how to translate an unsupported native coordinate. + +## Operation summary + +| Method | Capability | Result | +| --- | --- | --- | +| `provider.resolve_address` | Required | Canonical native coordinates | +| `provider.get` | `provider.get` | Found value or miss | +| `provider.get_many` | `provider.get_many` and `provider.get` | Per-name found value or miss | +| `provider.exists` | `provider.exists` | Presence without exposing a value | +| `provider.set` | `provider.set` | Stored | +| `provider.set_expiring` | `provider.set_expiring` and `provider.set` | Stored with backend lifetime bound | +| `provider.delete` | `provider.delete` | Idempotent deleted/not present | +| `provider.clear` | `provider.clear` | Idempotent bounded bulk invalidation | +| `provider.check_writable` | `provider.check_writable` | Address-specific mutation preflight | +| `provider.check_deletable` | `provider.check_deletable` | Address-specific deletion preflight | +| `provider.describe_write_target` | `provider.describe_write_target` | Non-secret destination description | +| `provider.reflect` | `provider.reflect` | Value-free declarations | + +No data operation is implicitly mandatory. A write-only CI secret sink can +advertise `set`, `exists`, `delete`, and `reflect` without `get`. The host MUST +fail a value read with `capability_required`; it must never turn the absence of +`get` into a false miss. + +## Resolve an address + +`provider.resolve_address` compiles a convention address or validates a native +address and returns the exact native coordinates used by all operations. It +backs `Provider::convention_address`, `Provider::entry_coordinates`, and +destructive same-entry checks. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "provider.resolve_address", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "convention", + "project": "payments", + "profile": "production", + "key": "DATABASE_PASSWORD" + } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "coordinates": { + "item": "payments/production/DATABASE_PASSWORD", + "field": null, + "vault": null, + "section": null, + "version": null + } + } +} +``` + +The result is naming only and must not perform provider I/O. Repeated calls +with the same initialized session and address MUST return the same coordinates. + +## Read operations + +### `provider.get` + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "provider.get", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "native", + "coordinates": { "item": "database", "field": "password" } + } + } +} +``` + +A hit and a miss are successful, distinct results: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "status": "found", + "value": "secret text", + "expires_at_unix_ms": 1786770000000 + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 3, "result": { "status": "missing" } } +``` + +An unavailable, unauthorized, malformed, or otherwise failed lookup is an +error, never `missing`. + +`expires_at_unix_ms` (0.20+) is required on a found result and may be null. A +timestamp is the provider's authoritative absolute bound on the validity of the +secret itself, not a cache freshness time or a promise that no earlier +revocation can happen. The endpoint MUST NOT knowingly return a value at or +after its reported expiry. Null means the provider does not know or does not +expose a validity bound; it never means the secret is permanent. + +SecretSpec carries this field through its own cache separately from cache +freshness. Providers such as passive keyrings usually return null. Providers +backed by expiring tokens, leases, certificates, or similar credentials should +return the bound they can authoritatively establish. + +### `provider.get_many` + +Batch reads carry names only as correlation keys. Each address retains its +canonical form and a batch may mix convention and native addresses. + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "provider.get_many", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "requests": [ + { + "name": "DATABASE_PASSWORD", + "address": { + "kind": "native", + "coordinates": { "item": "database", "field": "password" } + } + }, + { + "name": "API_TOKEN", + "address": { + "kind": "convention", + "project": "payments", + "profile": "production", + "key": "API_TOKEN" + } + } + ] + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "results": [ + { + "name": "DATABASE_PASSWORD", + "status": "found", + "value": "secret text", + "expires_at_unix_ms": null + }, + { "name": "API_TOKEN", "status": "missing" } + ] + } +} +``` + +The request and response preserve input order and contain one result per input +name. Names MUST be unique and there may be at most 1,024 requests. Identical +addresses should be fetched once and share the outcome. A backend failure fails +the whole batch; version 1 has no partial per-item errors. + +When `provider.get_many` is absent, the host performs bounded concurrent +`provider.get` calls. It must honor the negotiated in-flight limit. + +### `provider.exists` + +Presence checks support providers that can list names but intentionally cannot +return values, such as CI secret sinks. + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "provider.exists", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "convention", + "project": "payments", + "profile": "production", + "key": "DEPLOY_TOKEN" + } + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 5, "result": { "exists": true } } +``` + +When `exists` is absent but `get` is available, the adapter may implement a +presence check with `get` and discard the value in zeroizing storage. It must +not do the reverse: an `exists: true` result cannot satisfy a value read. + +## Write operations + +### `provider.set` + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "provider.set", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "native", + "coordinates": { "item": "database", "field": "password" } + }, + "value": "new secret text" + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 6, "result": { "stored": true } } +``` + +The endpoint MUST apply the same address policy as +`provider.check_writable`. A successful response means a subsequent operation +in the same backend consistency domain can observe the write. + +### `provider.set_expiring` + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "provider.set_expiring", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "native", + "coordinates": { "item": "database", "field": "password" } + }, + "value": "cached secret text", + "ttl_ms": 3600000 + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 7, "result": { "stored": true } } +``` + +`ttl_ms` is a positive integer. Its interval begins when the endpoint accepts +the request. An endpoint advertising this capability MUST ensure the stored +copy becomes unavailable no later than that interval, including without +another SecretSpec process running. It may expire it earlier only if the +backend's documented precision requires rounding. + +This is a retention bound on the stored copy. It is distinct from +`expires_at_unix_ms` on `provider.get`, which describes the validity of the +secret represented by the bytes. A provider may store an entry for one hour +whose credential becomes invalid in ten minutes; the read reports the +ten-minute validity bound. + +When this capability is absent, the external adapter follows the embedded +`Provider::set_expiring` fallback policy and calls ordinary `set` only when the +SecretSpec cache layer remains the freshness authority. A caller that requires +store-enforced expiry must check the capability and fail closed. + +### `provider.delete` + +```json +{ + "jsonrpc": "2.0", + "id": 8, + "method": "provider.delete", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "native", + "coordinates": { "item": "database", "field": "password" } + } + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 8, "result": { "deleted": false } } +``` + +Deletion is idempotent. `deleted` is true only when this request removed an +existing entry; an absent entry is a successful `false` result. + +### `provider.clear` + +`clear` is an optional bulk invalidation operation intended for providers that +act as caches. Its scope is always bounded by the provider URI used to +initialize the endpoint. + +Clear all entries owned by that configured provider instance: + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "method": "provider.clear", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "scope": { "kind": "all" } + } +} +``` + +Clear one convention namespace: + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "method": "provider.clear", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "scope": { + "kind": "convention", + "project": "payments", + "profile": "production" + } + } +} +``` + +```json +{ "jsonrpc": "2.0", "id": 9, "result": { "cleared": 12 } } +``` + +Clear is idempotent. `cleared` is the number of entries actually removed. +`all` MUST NOT mean an entire account, vault, or agent unless the initialized +provider URI itself denotes exactly that bounded namespace. An endpoint unable +to prove the bound must reject the request with `conflict`. + +The current Rust `Provider` trait has no bulk-clear method. Implementing this +capability requires adding a capability-aware `clear` seam or routing it only +through the external cache adapter. It must not be simulated by enumerating an +unbounded backend. + +## Mutation preflight + +Address-specific policies require optional preflight calls: + +```json +{ + "jsonrpc": "2.0", + "id": 10, + "method": "provider.check_writable", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "native", + "coordinates": { "item": "database", "version": "3" } + } + } +} +``` + +A writable/deletable address returns `{}`. A refusal uses +`permission_denied`, `conflict`, or `capability_required` without echoing the +address. `provider.set`/`set_expiring` and `provider.delete` MUST enforce the +same decision even if the host skipped preflight. + +If the endpoint advertises a mutation but not its preflight capability, the +adapter treats the operation capability as a global preflight success. This is +appropriate only when every accepted address has the same policy. + +## Describe a write target + +`provider.describe_write_target` returns the non-secret destination shown +before a CLI prompt: + +```json +{ + "jsonrpc": "2.0", + "id": 11, + "method": "provider.describe_write_target", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "address": { + "kind": "convention", + "project": "payments", + "profile": "production", + "key": "DATABASE_PASSWORD" + } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 11, + "result": { "description": "Example provider namespace payments/production" } +} +``` + +The description MUST NOT contain a secret value, credential, full sensitive +URI, or data obtained by reading the backing store. When the capability is +absent, the adapter renders the coordinates returned by +`provider.resolve_address`. + +## Reflect declarations + +`provider.reflect` discovers declarations without returning their values: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "method": "provider.reflect", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "project": "payments", + "profile": "production" + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "result": { + "schema_version": 1, + "declarations": { + "DATABASE_PASSWORD": { + "description": "Discovered from the example provider", + "required": true, + "ref": { "item": "database", "field": "password" } + } + } + } +} +``` + +Declaration objects use the version 1 SecretSpec secret-declaration schema. +They MUST NOT contain `default`, generated values, provider values, credentials, +or values disguised as descriptions. Discovery must be bounded by the supplied +project/profile and provider URI. Empty results are successful. + +## Lifecycle, reconnect, and concurrency + +- One endpoint process serves one provider URI and one declared session + context. +- Successful `rpc.initialize` is readiness on Linux, macOS, and Windows. +- The endpoint must keep reading while operations run, accept cancellation, + and enforce the negotiated in-flight limit. +- EOF, `rpc.shutdown`, or host death closes backend sessions and zeroizes + credentials and values held for the session. +- Shutdown has a 5-second maximum grace period before platform process + termination. +- A crashed endpoint may be relaunched for a later request. The host MUST NOT + automatically replay the request that observed the disconnect. `get` can + unlock, prompt, refresh, or audit; mutations may already have committed. +- A new endpoint receives a new initialization exchange. Request IDs, + authentication state, and any endpoint-local replay cache are not reused. + +Exactly one terminal response is guaranteed as described by the wire protocol; +exactly-once backend execution is not. Endpoints should make `set` naturally +idempotent for one address/value where their backend permits it. `delete` and +`clear` are required to be idempotent. + +## Errors and redaction + +Provider endpoints map failures to the common +[structured errors](/reference/ipc-wire#errors). Recommended mappings are: + +| Provider condition | Kind | +| --- | --- | +| Invalid or unsupported address | `invalid_params` | +| Method not advertised | `capability_required` | +| Authenticated caller lacks a grant | `permission_denied` | +| Unlock or interactive login needed | `interaction_required` | +| Temporary backend outage or capacity limit | `unavailable` | +| Version-pinned write, ambiguous clear, or same-entry conflict | `conflict` | +| Other backend failure | `operation_failed` | + +Backend error text is not stable and may contain values, names, URLs, HTTP +bodies, paths, or account data. An endpoint MUST NOT copy it into the IPC +`message`, `data`, stdout diagnostics, or stderr. Logs use method, request ID, +duration, and stable error kind only; whether secret names are safe is not left +to individual providers. + +## Authorization boundary + +The stdio pipes authenticate possession of the child-process handles; they do +not authenticate an original application to a provider-owned service behind +the endpoint. + +For version 1, the installed provider endpoint executable is the principal for +that second hop. A provider-owned service can therefore grant operations to the +exact endpoint executable it authenticates over its native transport. + +The endpoint MUST NOT accept `application_id`, executable path, PID, user ID, +or signer identity from the provider request and forward it as authenticated +identity. No such field exists in version 1. Per-application grants across both +hops require the cryptographic delegation described in the +[IPC architecture](/reference/ipc-architecture#principal-and-delegation-decision). + +This decision also means every process able to control or legitimately invoke +the authorized endpoint can exercise its service grant. Package permissions +and endpoint registration are therefore part of the security boundary. diff --git a/docs/src/content/docs/reference/resolver-protocol.md b/docs/src/content/docs/reference/resolver-protocol.md new file mode 100644 index 000000000..90ff2f028 --- /dev/null +++ b/docs/src/content/docs/reference/resolver-protocol.md @@ -0,0 +1,648 @@ +--- +title: Secret Resolution Protocol +description: Version 1 IPC contract between an SDK or application and the SecretSpec resolver +--- + +The Secret Resolution Protocol is the northbound IPC boundary between an +application and a complete SecretSpec resolver. It lets a consumer resolve one +declared name without linking SecretSpec or any provider SDK into the consumer. + +It uses the [shared IPC wire protocol](/reference/ipc-wire) with application +protocol name `secretspec.resolver` and version `1`. + +:::caution[Version compatibility] +The Secret Resolution Protocol and `secretspec serve` are available +starting with SecretSpec 0.20. +::: + +## Scope + +Version 1 deliberately exposes less than the embedded Rust API: + +- bind one session to a fixed manifest, provider override, profile, scope, and + access reason; +- resolve one exact declared secret name and only its composed dependencies; +- return an inline UTF-8 value or a resolver-owned temporary file; +- release path leases explicitly or by closing the session; +- store or remove one exact declared secret name (0.20+), when the endpoint + advertises the optional mutation capabilities. + +The resolver owns manifest parsing, inheritance, routing, caching, generation, +prompting, composition, audit events, provider credentials, and provider IPC. +Those internals are not represented in this protocol. + +Whole-profile resolution, provider enumeration, and arbitrary manifest queries +are not part of `secretspec.resolver/1`. Existing SDKs that need the complete +embedded API continue to use the Rust core or `libsecretspec`. New client +methods can be added behind capabilities after their least-authority semantics +are specified. + +## Starting the resolver + +The version 1 resolver transport is a directly launched child: + +```text +secretspec serve +``` + +An endpoint that must not accept writes is launched as `secretspec serve +--read-only` (0.20+), which advertises resolution only. + +The executable and arguments come from trusted application or administrator +configuration. The launcher MUST invoke the executable directly rather than +through a shell. A privileged caller MUST use an absolute executable path and +must not let a project manifest, flake, repository, or working directory +select it. An interactive unprivileged SDK may resolve `secretspec` through its +normal installation mechanism. + +The caller normally sends `rpc.initialize` immediately and applies a 5-second +startup timeout by default. Inspection tooling may first call `rpc.discover` +(0.20+), which does not initialize the resolver. A successful initialization +response is the readiness signal. The resolver must not open a global socket, +daemonize, or detach from its parent in stdio mode. + +Protocol stdin and stdout are exclusively reserved for framed IPC messages. +The stdio resolver MUST NOT prompt through stdin, stdout, or stderr. It has no +terminal of its own: the one process that can reach a person is the one that +launched it. When a value can only come from a person, the resolver therefore +asks the client, over the same session, with the +[`client.prompt` callback](#ask-the-client-for-a-value) (0.20+). + +Resolution may also wait for an independent provider-owned interaction channel +within the request deadline; when neither that nor a client callback is +available, the resolver returns `interaction_required`. Neither the resolver nor +the client automatically replays that request. + +## Session initialization + +The initialization `application` object fixes the resolver configuration for +the lifetime of the connection: + +```json +{ + "manifest": { + "kind": "path", + "path": "/home/alice/project/secretspec.toml" + }, + "provider": null, + "profile": "production", + "scope": "deploy", + "reason": "build api container", + "requested_authorization_duration_ms": 28800000 +} +``` + +`manifest` is one of: + +```json +{ "kind": "path", "path": "/absolute/path/secretspec.toml" } +``` + +```json +{ + "kind": "inline", + "toml": "[project]\nname = \"example\"\nrevision = \"1.0\"\n", + "base_dir": "/absolute/project/directory" +} +``` + +Rules: + +- A path and an inline `base_dir` MUST be absolute and lexically normalized by + the client. The resolver resolves filesystem identity and symlinks according to + the same rules as the embedded resolver. +- Working-directory discovery is not supported. The resolver's working directory + must never select a manifest implicitly. +- `provider`, when non-null, is the same provider override accepted by the + embedded `Secrets` builder. The resolver treats it as sensitive because an + input URI may contain credentials even though provider display URIs may not. +- `profile`, `scope`, and `reason` are nullable strings with the embedded API's + meaning. Null requests the resolver's configured/default value; it does not + consult a resolver-specific environment variable. +- `requested_authorization_duration_ms` is an optional positive app-requested + default for provider approval surfaces. The provider and approving user may + choose a different lifetime. +- The complete object is immutable after initialization. A caller needing + another profile, scope, reason, authorization-duration request, or manifest + opens another session. +- Inline TOML is sensitive transport data because a manifest can contain + defaults. It must never be logged. + +The resolver parses and validates the configuration before returning successful +initialization. Provider I/O remains lazy until a resolve request. On success, +the initialization response contains: + +```json +{ + "manifest_kind": "path", + "supports_inline_manifest": true +} +``` + +The response does not echo paths, configuration, provider URIs, profile names, +scope names, or reasons. + +The server-advertised application capabilities MUST include: + +- `resolver.get` +- `resolver.release` + +They MAY additionally include the mutation capabilities (0.20+): + +- `resolver.set` +- `resolver.delete` + +A client MUST NOT send a method the endpoint did not advertise, and an endpoint +answers one it did not advertise with `capability_required`. Storage is +advertised separately from resolution because a consumer that reads a token +usually has no business replacing it, and because an operator may run the +endpoint read-only. `secretspec serve` advertises both mutation methods; +`secretspec serve --read-only` advertises neither. + +A read-only endpoint also refuses any resolution that would store what it +produced. Withholding the two mutation methods does not by itself make a session +read-only, because minting a `generate = true` value or accepting a `prompt = +true` answer writes it back; see [failure mapping](#failure-mapping). + +## Resolve an exact name + +`resolver.get` resolves one exact name on the session's active profile and +scope. The resolver uses the same least-access behavior as +`Secrets::resolve_named`: an unrelated required secret cannot fail the request, +and only composition inputs of the requested secret may be read. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "resolver.get", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "name": "FORGE_TOKEN", + "representation": "value", + "purpose": { + "consumer": "nix", + "operation": "fetch", + "host": "github.com", + "path": "/acme/project" + } + } +} +``` + +`name` is a SecretSpec declaration name, not a provider-native address. It is +validated exactly as a manifest name and MUST fit within 4,096 UTF-8 bytes. + +`representation` is: + +| Value | Behavior | +| --- | --- | +| `auto` | Return the representation selected by the declaration's `as_path` setting | +| `value` | Require an inline value; fail with `representation_mismatch` for an `as_path` declaration | +| `path` | Require an `as_path` declaration and return a location to read the secret from; fail with `representation_mismatch` for an inline declaration | + +Both spellings describe what the returned string contains: the secret itself, +or a location to read it from. The file the resolver writes for the `path` form +is how that location is produced, not something the caller selects. + +Requiring the representation prevents a token consumer from accidentally using a +path as a token, or a path consumer from treating secret contents as a path. + +`purpose` is mandatory attribution for this call. `consumer` and `operation` +are non-empty strings of at most 256 UTF-8 bytes. `host` and `path` are optional +strings of at most 4,096 bytes; omit them when they do not apply. The resolver may +include this context in its protected audit event, but must otherwise treat it +as sensitive metadata. + +Purpose is not identity. The resolver and providers MUST NOT use these +caller-supplied strings for authorization, and purpose does not satisfy a +manifest's required access `reason`. In particular, a provider-owned agent must +not treat `consumer: "nix"` or a forwarded path as authenticated delegation. + +### Undeclared + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { "status": "undeclared" } +} +``` + +The name is absent from the active profile or hidden by the active scope. These +cases intentionally have the same result so a scope does not reveal names it +hides. No provider is contacted. + +### Missing + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "status": "missing", + "required": true + } +} +``` + +The declaration is visible but produced no value. This is a domain result, not +a transport error. The caller decides whether a missing required value is +fatal for its operation. + +### Inline value + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "status": "resolved", + "representation": "value", + "value": "secret text", + "source": "provider", + "source_provider": "keyring://", + "expires_at_unix_ms": null, + "refresh_at_unix_ms": null + } +} +``` + +`source` is one of `provider`, `generated`, `default`, or `composed`. The set is +closed for the resolver and open for the client: a later revision may name +another origin, so a client MUST decode a value it does not know as an +unrecognized origin rather than failing the response. A client that treats +provenance as security-relevant reads an unrecognized origin as one it cannot +vouch for and decides accordingly. +`source_provider` is present only for provider results and MUST be the +credential-free display URI returned by `Provider::uri()`. + +Both absolute timestamps are available starting with SecretSpec 0.20. They are +required and nullable, but they answer different questions: + +- `expires_at_unix_ms` is the provider-reported time at which the secret itself + ceases to be valid. At or after this time the consumer MUST stop using the + value and resolve it again. Null means the provider did not report a validity + bound; it does not mean the secret is permanent. +- `refresh_at_unix_ms` is the time at which SecretSpec considers its cached copy + stale. At or after this time a new `resolver.get` consults the authoritative + route instead of serving the copy. It says nothing about whether a copy the + consumer already holds still works. + +A cached result may carry both. SecretSpec preserves the provider's secret +expiry through its cache and never sets cache freshness later than known secret +validity. A directly read provider value normally has a null refresh time. A +composed value reports the earliest known secret expiry and earliest known +refresh time among its dependencies. + +Neither clock detects early revocation. If a remote service refuses a value, +the application handles that service error according to its own protocol. It +may resolve the secret again later, but that read follows the ordinary cache +policy; SecretSpec does not infer why the remote service refused it, force a +refresh, or instruct the provider to mutate stored material. A consumer must +not infer that a null expiry means a value is permanent. + +Both fields are unrelated to a path lease: a leased path must still be released +even if its secret expires or its cached copy becomes stale, and release may +remove it earlier. + +The result carries secret data. The client should copy it directly into its +final protected destination and release its JSON/frame buffers promptly. + +### Leased path + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "status": "resolved", + "representation": "path", + "path": "/run/user/1000/secretspec/session-random/secret-random", + "path_lease_id": "Qk7jXGfOLpLzmvYxjOxvMw", + "source": "provider", + "source_provider": "file:./credentials", + "expires_at_unix_ms": 1786770000000, + "refresh_at_unix_ms": null + } +} +``` + +The resolver, not the client, owns the file behind the path. `path_lease_id` is +an unpredictable, session-local opaque token containing at least 128 bits of +randomness. The client must not parse it and must not send it on another +connection. + +A lease here is a handle over a file this resolver owns, and nothing else. A +provider can report the value's validity through `expires_at_unix_ms`, but +renewal and explicit revocation of a dynamic credential lease remain a +[reserved extension](/reference/ipc-architecture#reserved-for-dynamic-secrets) +with distinctly named members. This lease is named for the path it releases so +the two can never be confused. + +A lease is a lifetime handle, not a read capability. Holding the lease ID is +not what permits reading the file, and not holding it is not what prevents it: +the file is protected by its own permissions, so any process running as the +resolver user can open the path for as long as the file exists. Releasing a +lease shortens that window; it does not narrow who may read within it. A caller +that needs a secret withheld from other processes at the same privilege level +needs process isolation, not a lease, and should prefer the `value` +representation so nothing reaches the filesystem at all. + +On POSIX systems, the resolver creates a mode-0700 session directory and a +mode-0400 regular file owned by the resolver user. It must defend against +symlinks and path replacement. On Windows, the directory and file receive a +non-inheriting ACL limited to the resolver user and required system identities. +The response path is absolute. + +The resolver retains the file until the first of: + +- every lease referring to it has been released; +- the client disconnects; +- `rpc.shutdown` cleans the session; +- the resolver exits. + +If resolution is cancelled, its deadline expires, or its result cannot be +written, any newly created file is removed without creating a client-visible +lease. Resolver startup should remove abandoned directories created by earlier +crashes only after verifying ownership, type, and a conservative age bound. + +## Release path leases + +`resolver.release` releases one or more leases: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "resolver.release", + "_meta": { "deadline_unix_ms": 1786766406000 }, + "params": { + "path_lease_ids": ["Qk7jXGfOLpLzmvYxjOxvMw"] + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { "released": 1 } +} +``` + +Release is idempotent. An unknown, already released, or duplicate lease ID is a +successful no-op and is not included in `released`. One request may contain at +most 256 IDs. + +SDK destructors MUST NOT perform an unbounded blocking call. They should queue +lease IDs in session-owned memory and flush them on an explicit close, the next +safe IPC call, or bounded session shutdown. Disconnect remains the final +cleanup mechanism. + +## Ask the client for a value + +:::caution[Version compatibility] +`client.prompt` is available starting with SecretSpec 0.20. +::: + +This is the one method the resolver sends and the client answers. A declaration +with `prompt = true` and no stored value can only be satisfied by a person, and +the resolver cannot reach one: its stdin and stdout are the protocol, and its +stderr is a diagnostic channel the launcher captures. So it asks the process +that launched it, which does have a terminal or a window. + +A client that can ask a person advertises the callback during initialization: + +```json +{ + "protocol": "secretspec.resolver", + "versions": [1], + "client": { "name": "cargo", "version": "1.94.0" }, + "client_methods": ["client.prompt"], + "limits": { "max_frame_bytes": 1048576, "max_in_flight": 8 }, + "application": {} +} +``` + +The resolver then sends, on the same connection, while a `resolver.get` is in +flight: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "client.prompt", + "_meta": { "deadline_unix_ms": 1786766405000 }, + "params": { + "name": "DEPLOY_PASSWORD", + "profile": "production", + "target_provider": "keyring://" + } +} +``` + +The client answers with the value the person entered: + +```json +{ "jsonrpc": "2.0", "id": 1, "result": { "value": "entered by a person" } } +``` + +Rules: + +- The resolver MUST NOT send `client.prompt` unless the client advertised it. A + client that did not is never asked, and a `prompt = true` declaration with no + stored value resolves to `status: missing` immediately. That is the point of + advertising: a headless consumer gets its answer at once instead of waiting + out a deadline on a question nobody would see. +- The request IDs the resolver allocates for callbacks are its own. They are + independent of the client's request IDs, and the two spaces may overlap. +- The callback carries no free-form message. The client composes what a person + reads from `name`, `profile`, and `target_provider`, so the resolver cannot + put arbitrary text in front of the person answering. +- `target_provider` is the credential-free display URI of the provider that + will store the answer. It is absent when the answer is used for this + resolution only and never stored. +- `value` is a secret. It is at least one byte, is treated exactly like a + resolved value in both directions, and MUST NOT be logged. An empty answer is + refused, for the same reason `resolver.set` refuses an empty value. +- The callback's deadline is never later than the `resolver.get` that raised it, + and cancelling that request cancels the callback. A person who never answers + cannot hold the resolver past the caller's own deadline. +- A client that declines answers with an error rather than an empty value; the + resolve then fails as `interaction_required`. +- Both sides MUST keep reading while a callback is outstanding. A client that + blocks its reader to ask a person deadlocks the session, because the same + connection carries the response it is waiting for. +- `libsecretspec-resolver` answers prompts without a callback (0.20+). Its ABI + deliberately hands no function pointer to a foreign runtime, so the caller + drives the answer instead: a session opened with the answer-prompts flag has + its calls report that a prompt is pending, and the caller takes it, answers or + declines it, and waits again. A session that does not set the flag advertises + nothing and is never asked. + +## Store a value + +:::caution[Version compatibility] +`resolver.set` and `resolver.delete` are available starting with SecretSpec +0.20, and only from endpoints that advertise them. +::: + +`resolver.set` stores one exact declared name on the session's active profile +and scope: + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "resolver.set", + "_meta": { "deadline_unix_ms": 1786766407000 }, + "params": { + "name": "FORGE_TOKEN", + "value": "secret text", + "purpose": { + "consumer": "cargo", + "operation": "login", + "host": "crates.io" + } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "status": "stored", + "target_provider": "keyring://" + } +} +``` + +`name` and `purpose` carry the meaning they have for `resolver.get`. `value` is +the secret as a UTF-8 string of at least one byte, and it is transport-sensitive +in the same way a resolved value is. An empty value is rejected: stores disagree +about whether one means "absent" or "present and empty", and that difference +decides what a later read finds. A caller that wants the value gone sends +`resolver.delete`. + +`target_provider`, when present, is the credential-free display URI of the +provider that took the write. + +Rules: + +- The value lands where `resolver.get` for the same name on the same session + would look for it. A consumer that stores and then resolves never has to model + the resolver's routing. +- Only the primary write provider of the name's route is written. A fallback + chain is not traversed and no copy is written to the stores behind it. +- The active scope bounds a write exactly as it bounds a read: a name the scope + does not offer is not a name the session may store. +- A name the profile does not declare has no address to write to. Unlike a + missing value, this is not a state the caller can route around, so it is + `operation_failed` rather than a status. +- A composed, extracted, or otherwise derived declaration is read-only, and so + is a provider that does not accept writes. Both are refused before the value + is sent anywhere. +- The resolver does not prompt, here as anywhere else in this protocol. A store + that needs an interaction the resolver cannot provide returns + `interaction_required`. + +## Remove a value + +`resolver.delete` removes one exact declared name's stored value from the same +provider `resolver.set` would write to: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "resolver.delete", + "_meta": { "deadline_unix_ms": 1786766408000 }, + "params": { + "name": "FORGE_TOKEN", + "purpose": { + "consumer": "cargo", + "operation": "logout", + "host": "crates.io" + } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "status": "deleted", + "deleted": true, + "target_provider": "keyring://" + } +} +``` + +Removal is idempotent: a name the store held nothing for reports `deleted: +false` and is a success. Every rule listed for `resolver.set` applies, including +the scope bound and the single write provider. A successful removal also +invalidates any cached copy, so a later resolve cannot return the value that was +just removed. + +## Failure mapping + +The common [error table](/reference/ipc-wire#errors) is deliberately smaller +than `SecretSpecError`. Resolver implementations map errors as follows: + +| Resolver condition | IPC result or error | +| --- | --- | +| Name absent or hidden by scope | `status: undeclared` result | +| Visible declaration has no value | `status: missing` result | +| Wrong requested representation | `representation_mismatch` | +| Reason policy requires interaction with no available channel | `interaction_required` | +| Provider authorization refusal | `permission_denied` | +| Read would store a generated or prompted value on a read-only endpoint | `permission_denied` | +| Provider temporarily unreachable | `unavailable` only when retry safety is known; otherwise `operation_failed` | +| Name absent or hidden by scope on a mutation | `operation_failed` | +| Declaration or provider does not accept writes | `operation_failed` | +| Mutation method the endpoint did not advertise | `capability_required` | +| Manifest, profile, scope, generation, composition, or other resolver error | `operation_failed` | + +The resolver may retain a full local error for protected diagnostics, but the IPC +error must follow the wire protocol's redaction rules. In particular, it must +not serialize `SecretSpecError::to_string()` directly. + +## Reconnect and retry + +A child exit or broken pipe fails every in-flight request with a local +transport error and invalidates every lease. The client may launch a fresh +resolver for later work, but it MUST NOT replay the failed request +automatically. Reads can prompt, generate and persist a secret, refresh a +cache, or emit an audit event, so even `resolver.get` is not assumed free of +side effects. + +A `resolver.set` or `resolver.delete` that was cancelled, expired, or lost with +its transport has an outcome the client cannot infer: the write may already have +reached the store. The caller learns what happened by resolving the name on a +fresh session, not by repeating the mutation. + +The new process performs initialization from the beginning. Session state, +request IDs, and lease IDs are never reused. + +## SDK integration policy + +The wire contract, not either implementation API, is canonical. SDKs have three supported runtime +strategies: + +1. Keep embedding the Rust resolver, as existing SDKs do today. +2. Use the Rust `secretspec-ipc` client for Rust resolver mode. Its default client + is async; its `blocking` feature serves consumers that have no async runtime + and should not take on one, such as a build tool reading a single token. +3. Bind the pure-C `libsecretspec-resolver` client for non-Rust resolver mode. + +Independent implementations may implement the canonical wire protocol directly, +but supported non-Rust SecretSpec SDKs must not create per-language client state +machines. The Rust and C clients pass the same language-neutral conformance +suite and differential tests. Language packages expose the backend choice +explicitly; silently changing an embedded SDK to launch a process would alter +deployment, prompting, lifecycle, and trust behavior. diff --git a/docs/src/content/docs/sdk/csharp.mdx b/docs/src/content/docs/sdk/csharp.mdx index 50294059e..31f5ff3c4 100644 --- a/docs/src/content/docs/sdk/csharp.mdx +++ b/docs/src/content/docs/sdk/csharp.mdx @@ -16,6 +16,10 @@ import asPathExample from '../../../../../secretspec-dotnet/examples/as_path/AsP > NuGet package is an unsupported bootstrap artifact used to reserve the > package ID; use version 0.16 or later for the API below. +> **Native library name:** SecretSpec 0.20+ packages the embedded C ABI as +> `libsecretspec` (`libsecretspec.*`). The runtime loader +> still accepts the pre-0.20 `secretspec_ffi` filenames. + The C# SDK (`Cachix.SecretSpec`) 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 C#-side resolution logic. @@ -125,6 +129,6 @@ to remove these files deterministically: ## Native loading The NuGet runtime asset is selected automatically. For local SDK development, -`SECRETSPEC_FFI_LIB` can point to a particular `libsecretspec_ffi` build. From +`SECRETSPEC_FFI_LIB` can point to a particular `libsecretspec` 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/go.mdx b/docs/src/content/docs/sdk/go.mdx index cda817c43..eecf9b79a 100644 --- a/docs/src/content/docs/sdk/go.mdx +++ b/docs/src/content/docs/sdk/go.mdx @@ -8,11 +8,17 @@ import quickStartExample from '../../../../../secretspec-go/examples/quick_start import scopesExample from '../../../../../secretspec-go/examples/scopes/main.go?raw'; import typedAccessExample from '../../../../../secretspec-go/examples/typed_access/main.go?raw'; -The Go SDK (`secretspec-go`) is a thin client over the `secretspec-ffi` C ABI, +The Go SDK (`secretspec-go`) is a thin client over the `libsecretspec` C ABI, loaded via [purego](https://github.com/ebitengine/purego) (dlopen, no cgo). Resolution happens in the Rust core, so the SDK inherits every provider with no Go-side logic. +:::note[SecretSpec 0.20+ library name] +`libsecretspec` was named `secretspec-ffi` through SecretSpec 0.19. The current +Go loader prefers the new artifact name and still accepts pre-0.20 shared +libraries. +::: + ## Quick start @@ -54,7 +60,7 @@ $ secretspec schema | quicktype -s schema --top-level SecretSpec --lang go -o se ## Library discovery -The native `secretspec-ffi` cdylib is resolved at runtime, in order: +The native `libsecretspec` cdylib is resolved at runtime, in order: 1. The `SECRETSPEC_FFI_LIB` environment variable (an explicit path). 2. A library embedded at build time with `-tags embed_lib`. @@ -62,7 +68,7 @@ The native `secretspec-ffi` cdylib is resolved at runtime, in order: (the development path). The SDK uses [purego](https://github.com/ebitengine/purego), so the cdylib is -loaded at runtime, not linked. Either install/build `libsecretspec_ffi` and set +loaded at runtime, not linked. Either install/build `libsecretspec` and set `SECRETSPEC_FFI_LIB`, or stage the per-platform library into `lib/` and build with `-tags embed_lib` for a self-contained binary. The embedded library is extracted to a per-user, owner-only cache directory at first use, and is not @@ -71,7 +77,7 @@ distributed through the Go module proxy. ## Static linking For a self-contained binary with no runtime library to locate, build with -`-tags static` instead. This uses cgo and links `libsecretspec_ffi.a` directly +`-tags static` instead. This uses cgo and links `libsecretspec.a` directly into the Go binary. In a development checkout: ```bash @@ -86,7 +92,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -$ bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +$ bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same build command for either type: diff --git a/docs/src/content/docs/sdk/haskell.mdx b/docs/src/content/docs/sdk/haskell.mdx index 7305690f8..5ee9b73ed 100644 --- a/docs/src/content/docs/sdk/haskell.mdx +++ b/docs/src/content/docs/sdk/haskell.mdx @@ -8,10 +8,15 @@ import quickStartExample from '../../../../../secretspec-hs/examples/QuickStart. import scopesExample from '../../../../../secretspec-hs/examples/Scopes.hs?raw'; import reportExample from '../../../../../secretspec-hs/examples/Report.hs?raw'; -The Haskell SDK (`secretspec-hs`) is a thin client over the `secretspec-ffi` C +The Haskell SDK (`secretspec-hs`) is a thin client over the `libsecretspec` C ABI, linked at build time via the Haskell FFI. Resolution happens in the Rust core, so the SDK inherits every provider with no Haskell-side logic. +:::note[SecretSpec 0.20+ library name] +`libsecretspec` was named `secretspec-ffi` through SecretSpec 0.19. New static +builds and pkg-config metadata use `libsecretspec.a` and `libsecretspec.pc`. +::: + ## Quick start @@ -60,12 +65,12 @@ $ secretspec schema | quicktype -s schema --top-level SecretSpec --lang haskell ## Building -The build links the `secretspec-ffi` archive statically. Stage the `.a` in a +The build links the `libsecretspec` archive statically. Stage the `.a` in a directory of its own (so the linker picks the archive, not the co-located `.so`) and pass its native dependencies to the linker: ```bash -$ cargo build -p secretspec-ffi +$ cargo build -p libsecretspec $ TARGET="$(cargo metadata --no-deps --format-version 1 \ | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" @@ -73,9 +78,9 @@ $ TARGET="$(cargo metadata --no-deps --format-version 1 \ # Stage the staticlib alone, and capture its native-static-libs for the linker. $ LIBDIR="$(mktemp -d)" -$ cp "$TARGET/debug/libsecretspec_ffi.a" "$LIBDIR/" +$ cp "$TARGET/debug/libsecretspec.a" "$LIBDIR/" -$ NATIVE_LIBS="$(cargo rustc -q -p secretspec-ffi --crate-type staticlib -- \ +$ NATIVE_LIBS="$(cargo rustc -q -p libsecretspec --crate-type staticlib -- \ --print native-static-libs 2>&1 | sed -n 's/^note: native-static-libs: //p' | tail -1)" $ cabal build --extra-lib-dirs="$LIBDIR" --ghc-options="-optl${NATIVE_LIBS// / -optl}" @@ -89,7 +94,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -$ bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +$ bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same Cabal flag for either type: diff --git a/docs/src/content/docs/sdk/overview.md b/docs/src/content/docs/sdk/overview.md index b133c1160..7e2837ab3 100644 --- a/docs/src/content/docs/sdk/overview.md +++ b/docs/src/content/docs/sdk/overview.md @@ -12,6 +12,10 @@ share one resolver. > package is an unsupported package-ID bootstrap; use version 0.16 or later > for the API shown in the C# guide. +> **Native library name:** Starting with SecretSpec 0.20, the embedded C ABI is +> `libsecretspec`. It was called `secretspec-ffi` through SecretSpec 0.19; +> current runtime SDKs retain shared-library filename compatibility. + ## One resolver, thin clients Resolution (providers, fallback chains, profiles, generation, `as_path` @@ -20,7 +24,7 @@ that core rather than a reimplementation: - **Rust** uses the library directly, with a compile-time derive macro for strongly-typed access. -- **Ruby** (a native C extension) statically links the `secretspec-ffi` C ABI +- **Ruby** (a native C extension) statically links the `libsecretspec` C ABI at build time; **Go** (purego) loads it at runtime with no cgo. Both exchange a small JSON request/response with the core. - **Haskell** links the same C ABI at build time via the GHC FFI. @@ -93,7 +97,7 @@ The resolver ships inside each package, so there is nothing extra to install and no runtime library path to set: - **Python** builds the resolver into a pyo3 extension shipped as a `cp39-abi3` - wheel, and **Ruby** statically links the `secretspec-ffi` archive into a + wheel, and **Ruby** statically links the `libsecretspec` archive into a native C extension in the gem. - **Haskell** statically links the same archive at build time via the GHC FFI. - **C# (0.16+)** ships the `cdylib` as runtime-specific native assets in one diff --git a/docs/src/content/docs/sdk/php.mdx b/docs/src/content/docs/sdk/php.mdx index 3f479803c..2932e3e36 100644 --- a/docs/src/content/docs/sdk/php.mdx +++ b/docs/src/content/docs/sdk/php.mdx @@ -22,10 +22,16 @@ native backends over an identical JSON contract: [ext-php-rs](https://github.com/davidcole1340/ext-php-rs)) embeds the resolver the way `pdo` or `redis` do. It needs no `ffi.enable` and works under PHP-FPM and the web SAPI out of the box — the recommended path for Laravel and Symfony. -- **`ext-ffi`** dlopens the `secretspec-ffi` shared library at runtime. Nothing +- **`ext-ffi`** dlopens the `libsecretspec` shared library at runtime. Nothing to compile, ideal for CLI tools and local development; requires the FFI extension enabled. +:::note[SecretSpec 0.20+ library name] +`libsecretspec` was named `secretspec-ffi` through SecretSpec 0.19. The PHP +loader prefers the new artifact name and still accepts pre-0.20 shared +libraries. +::: + The SDK prefers the extension whenever it is loaded and transparently falls back to `ext-ffi`, so your application code is the same either way. @@ -69,7 +75,7 @@ automatically. ### Option B — ext-ffi (quick start / CLI) -The FFI backend dlopens the `secretspec-ffi` library at runtime. Enable the +The FFI backend dlopens the `libsecretspec` library at runtime. Enable the bundled FFI extension — in CLI it is on by default; for the web SAPI set: ```ini @@ -84,7 +90,7 @@ not run it automatically): $ vendor/bin/secretspec-install-lib ``` -That downloads the right `secretspec-ffi` library from the matching GitHub +That downloads the right `libsecretspec` library from the matching GitHub release into the package. Alternatively, point `SECRETSPEC_FFI_LIB` at a library you built or placed yourself. The SDK looks at `SECRETSPEC_FFI_LIB` first, then the downloaded copy, then a local Cargo `target/` directory. @@ -182,7 +188,7 @@ lifetime. Call `$resolved->close()` when done to remove those temp files: The SDK chooses a backend automatically: if the `secretspec-php-native` extension is loaded it is used directly (no `ffi.enable`, no library to locate); otherwise -the SDK dlopens the `secretspec-ffi` library via `ext-ffi`, looking first at +the SDK dlopens the `libsecretspec` library via `ext-ffi`, looking first at `SECRETSPEC_FFI_LIB`, then the copy `vendor/bin/secretspec-install-lib` places in the package, then a local Cargo `target/` directory. Both backends call the identical Rust `resolve_json`, so the result is the same — a cross-language diff --git a/docs/src/content/docs/sdk/ruby.mdx b/docs/src/content/docs/sdk/ruby.mdx index 7724a3d92..2642c168f 100644 --- a/docs/src/content/docs/sdk/ruby.mdx +++ b/docs/src/content/docs/sdk/ruby.mdx @@ -8,10 +8,16 @@ import quickStartExample from '../../../../../secretspec-rb/examples/quick_start import scopesExample from '../../../../../secretspec-rb/examples/scopes.rb?raw'; import typedAccessExample from '../../../../../secretspec-rb/examples/typed_access.rb?raw'; -The Ruby SDK (`secretspec`) is a thin client over the `secretspec-ffi` C ABI, +The Ruby SDK (`secretspec`) is a thin client over the `libsecretspec` C ABI, linked into a native C extension at build time. Resolution happens in the Rust core, so the SDK inherits every provider with no Ruby-side logic. +:::note[SecretSpec 0.20+ library name] +`libsecretspec` was named `secretspec-ffi` through SecretSpec 0.19. The current +extension build prefers `libsecretspec.a` and still recognizes a bundled +pre-0.20 archive. +::: + ## Quick start @@ -55,7 +61,7 @@ $ secretspec schema | quicktype -s schema --top-level SecretSpec --lang ruby -o ## Native library -The published platform gems bundle the `secretspec-ffi` archive and statically +The published platform gems bundle the `libsecretspec` archive and statically link it into the mkmf extension at install time. ### Linking with pkg-config (0.19+) @@ -64,7 +70,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -$ bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +$ bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same extension flag for either type: diff --git a/libsecretspec-resolver/CMakeLists.txt b/libsecretspec-resolver/CMakeLists.txt new file mode 100644 index 000000000..76ec5a7e9 --- /dev/null +++ b/libsecretspec-resolver/CMakeLists.txt @@ -0,0 +1,152 @@ +cmake_minimum_required(VERSION 3.20) +project(libsecretspec_resolver VERSION 1.0.0 LANGUAGES C) + +option(SECRETSPEC_RESOLVER_BUILD_TESTS "Build C client tests" ON) + +include(GNUInstallDirs) +include(CheckLinkerFlag) +find_package(Threads REQUIRED) +find_package(yyjson CONFIG REQUIRED) + +# yyjson arrives as a static archive whose symbols carry default visibility, so +# linking it into the shared library would re-export roughly fifty yyjson +# symbols and let an application's own copy of yyjson interpose on the one this +# library calls. Keep them local, matching what the previously vendored build +# achieved by compiling yyjson with hidden visibility. Windows needs nothing: +# there only SECRETSPEC_RESOLVER_API marks a symbol dllexport. +check_linker_flag(C "LINKER:--exclude-libs,ALL" SECRETSPEC_RESOLVER_HAS_EXCLUDE_LIBS) +if(SECRETSPEC_RESOLVER_HAS_EXCLUDE_LIBS) + set(SECRETSPEC_RESOLVER_HIDE_YYJSON "LINKER:--exclude-libs,ALL") +else() + check_linker_flag(C "LINKER:-unexported_symbol,_yyjson*" + SECRETSPEC_RESOLVER_HAS_UNEXPORTED_SYMBOL) + if(SECRETSPEC_RESOLVER_HAS_UNEXPORTED_SYMBOL) + set(SECRETSPEC_RESOLVER_HIDE_YYJSON "LINKER:-unexported_symbol,_yyjson*") + else() + set(SECRETSPEC_RESOLVER_HIDE_YYJSON "") + endif() +endif() + +set(SECRETSPEC_RESOLVER_SOURCES + src/frame.c + src/json.c + src/secure_memory.c + src/session.c) + +if(WIN32) + list(APPEND SECRETSPEC_RESOLVER_SOURCES src/process_windows.c) +else() + list(APPEND SECRETSPEC_RESOLVER_SOURCES src/process_posix.c) +endif() + +add_library(secretspec_resolver_objects OBJECT ${SECRETSPEC_RESOLVER_SOURCES}) +set_target_properties(secretspec_resolver_objects PROPERTIES + C_STANDARD 11 + C_STANDARD_REQUIRED YES + C_EXTENSIONS NO + POSITION_INDEPENDENT_CODE YES + C_VISIBILITY_PRESET hidden) +target_include_directories(secretspec_resolver_objects PRIVATE include src) +target_link_libraries(secretspec_resolver_objects PRIVATE yyjson::yyjson) +target_compile_definitions(secretspec_resolver_objects PRIVATE SECRETSPEC_RESOLVER_BUILDING) +if(MSVC) + target_compile_options(secretspec_resolver_objects PRIVATE /W4 /WX /experimental:c11atomics) +else() + target_compile_options(secretspec_resolver_objects PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +# A Windows DLL must compile the public definitions with dllexport, while the +# static archive must not carry DLL export directives. Other platforms can +# safely share the hidden-visibility object set. +if(WIN32) + add_library(secretspec_resolver_shared_objects OBJECT ${SECRETSPEC_RESOLVER_SOURCES}) + set_target_properties(secretspec_resolver_shared_objects PROPERTIES + C_STANDARD 11 + C_STANDARD_REQUIRED YES + C_EXTENSIONS NO + POSITION_INDEPENDENT_CODE YES + C_VISIBILITY_PRESET hidden) + target_include_directories(secretspec_resolver_shared_objects PRIVATE include src) + target_link_libraries(secretspec_resolver_shared_objects PRIVATE yyjson::yyjson) + target_compile_definitions(secretspec_resolver_shared_objects PRIVATE + SECRETSPEC_RESOLVER_BUILDING SECRETSPEC_RESOLVER_SHARED) + if(MSVC) + target_compile_options(secretspec_resolver_shared_objects PRIVATE /W4 /WX /experimental:c11atomics) + else() + target_compile_options(secretspec_resolver_shared_objects PRIVATE + -Wall -Wextra -Wpedantic -Werror) + endif() + set(SECRETSPEC_RESOLVER_SHARED_OBJECTS $) +else() + set(SECRETSPEC_RESOLVER_SHARED_OBJECTS $) +endif() + +add_library(secretspec_resolver_static STATIC + $) +add_library(secretspec_resolver_shared SHARED + ${SECRETSPEC_RESOLVER_SHARED_OBJECTS}) +set_target_properties(secretspec_resolver_static PROPERTIES OUTPUT_NAME secretspec-resolver) +set_target_properties(secretspec_resolver_shared PROPERTIES + OUTPUT_NAME secretspec-resolver + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) +target_link_libraries(secretspec_resolver_static PUBLIC Threads::Threads) +target_link_libraries(secretspec_resolver_static PRIVATE yyjson::yyjson) +target_link_libraries(secretspec_resolver_shared PUBLIC Threads::Threads) +target_link_libraries(secretspec_resolver_shared PRIVATE yyjson::yyjson) +target_compile_definitions(secretspec_resolver_shared PUBLIC SECRETSPEC_RESOLVER_SHARED) +target_link_options(secretspec_resolver_shared PRIVATE ${SECRETSPEC_RESOLVER_HIDE_YYJSON}) +target_include_directories(secretspec_resolver_static PUBLIC include) +target_include_directories(secretspec_resolver_shared PUBLIC include) + +install(TARGETS secretspec_resolver_static secretspec_resolver_shared + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES include/secretspec_resolver.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +configure_file(secretspec-resolver.pc.in secretspec-resolver.pc @ONLY) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/secretspec-resolver.pc + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) + +if(SECRETSPEC_RESOLVER_BUILD_TESTS) + enable_testing() + add_executable(secretspec_resolver_smoke tests/smoke.c) + target_link_libraries(secretspec_resolver_smoke PRIVATE secretspec_resolver_static) + add_test(NAME secretspec_resolver_smoke COMMAND secretspec_resolver_smoke) + add_executable(secretspec_resolver_smoke_shared tests/smoke.c) + target_link_libraries(secretspec_resolver_smoke_shared PRIVATE secretspec_resolver_shared) + add_test(NAME secretspec_resolver_smoke_shared COMMAND secretspec_resolver_smoke_shared) + add_executable(secretspec_resolver_fake_peer tests/fake_peer.c) + target_link_libraries(secretspec_resolver_fake_peer PRIVATE yyjson::yyjson) + add_executable(secretspec_resolver_session tests/session.c) + target_link_libraries(secretspec_resolver_session PRIVATE secretspec_resolver_static) + add_test(NAME secretspec_resolver_session + COMMAND secretspec_resolver_session $) + add_executable(secretspec_resolver_backpressure tests/backpressure.c) + target_link_libraries(secretspec_resolver_backpressure PRIVATE secretspec_resolver_static) + add_test(NAME secretspec_resolver_backpressure + COMMAND secretspec_resolver_backpressure $) + add_executable(secretspec_resolver_regressions tests/regressions.c) + target_link_libraries(secretspec_resolver_regressions PRIVATE secretspec_resolver_static) + add_test(NAME secretspec_resolver_regressions + COMMAND secretspec_resolver_regressions $) + + foreach(test_target IN ITEMS + secretspec_resolver_smoke + secretspec_resolver_smoke_shared + secretspec_resolver_fake_peer + secretspec_resolver_session + secretspec_resolver_backpressure + secretspec_resolver_regressions) + set_target_properties(${test_target} PROPERTIES + C_STANDARD 11 + C_STANDARD_REQUIRED YES + C_EXTENSIONS NO) + if(MSVC) + target_compile_options(${test_target} PRIVATE /W4 /WX /experimental:c11atomics) + else() + target_compile_options(${test_target} PRIVATE -Wall -Wextra -Wpedantic -Werror) + endif() + endforeach() +endif() diff --git a/libsecretspec-resolver/README.md b/libsecretspec-resolver/README.md new file mode 100644 index 000000000..e565bd38b --- /dev/null +++ b/libsecretspec-resolver/README.md @@ -0,0 +1,80 @@ +# libsecretspec-resolver + +`libsecretspec-resolver` is the pure C11 client for SecretSpec IPC, available in +SecretSpec 0.20+. It contains no Rust or SecretSpec resolver/provider code. The +public ABI is +[include/secretspec_resolver.h](include/secretspec_resolver.h); JSON goes +through yyjson behind a private adapter, and no yyjson type appears in the ABI. + +## yyjson dependency + +yyjson is a build dependency rather than vendored source, resolved through +pkg-config (Meson, and the cc-rs build script of the workspace conformance +package) and through its CMake package config (`find_package(yyjson CONFIG)`). +0.12.0 is the version CI builds and tests against. Releases back to 0.8.0 +declare every API the client calls and compile it cleanly, so a distribution +package that lags behind should work as well, but only 0.12.0 is exercised +here. + +Provide it however the platform prefers: + +- Nix: `pkgs.yyjson`, already in `devenv.nix`. This needs a nixpkgs new enough + to carry [ibireme/yyjson#295](https://github.com/ibireme/yyjson/pull/295). + Without that patch `yyjson.pc` composes `libdir` and `includedir` by + concatenating the prefix with install dirs that nixpkgs passes as absolute + paths, so `pkg-config --cflags yyjson` points at a directory that does not + exist. `devenv.yaml` pins a nixpkgs commit that carries the patch and says + why. +- Homebrew: `brew install yyjson`. +- vcpkg: `vcpkg install yyjson`. +- Debian and Ubuntu: `libyyjson-dev`, present from Ubuntu 25.10 onward. +- From source, which is what CI does on every platform for one pinned version: + `scripts/install-yyjson.sh`. + +Runners without pkg-config can point the conformance build script at an install +prefix with `YYJSON_INCLUDE_DIR` and `YYJSON_LIB_DIR` instead. + +Because yyjson ships as a static archive whose symbols carry default +visibility, both build systems pass a linker flag that keeps those symbols out +of the shared library's exports. Without it an application's own copy of yyjson +could interpose on the one this library calls. Check after touching either +build system that the shared library still exports only `secretspec_resolver_*`: + +```console +nm -D --defined-only build/libsecretspec-resolver.so.1 | grep yyjson +``` + +Treat a yyjson security advisory as release-blocking whenever it affects +enabled parsing or writing code, even if no SecretSpec regression is known, and +bump the pinned version in `scripts/install-yyjson.sh` along with its digest. + +This client serves only `secretspec.resolver/1` (application or SDK to +resolver). The provider protocol's client is always SecretSpec itself and is +implemented in Rust. This library is separate from `libsecretspec`, the +embedded in-process resolver ABI. + +Build static and shared libraries plus the C-only smoke, session, +backpressure, and regression tests with either build system: + +```console +cmake -S . -B build -DSECRETSPEC_RESOLVER_BUILD_TESTS=ON +cmake --build build +ctest --test-dir build --output-on-failure +``` + +```console +meson setup build +meson compile -C build +meson test -C build +``` + +The client launches an exact executable without a shell, owns its child and +joinable workers, negotiates limits before application traffic, multiplexes +bounded calls, and performs deadline/cancellation/shutdown handling. Input +slices are borrowed only for a call. Returned buffers belong to the library and +must be released with `secretspec_resolver_buffer_free`. + +Both build systems compile the library and C-only tests as strict C11 with +warnings treated as errors. The workspace conformance package additionally +links these C sources into its differential property test and compares their +normalized call outcomes with the independent Rust client. diff --git a/libsecretspec-resolver/include/secretspec_resolver.h b/libsecretspec-resolver/include/secretspec_resolver.h new file mode 100644 index 000000000..943a75d1d --- /dev/null +++ b/libsecretspec-resolver/include/secretspec_resolver.h @@ -0,0 +1,187 @@ +#ifndef SECRETSPEC_RESOLVER_H +#define SECRETSPEC_RESOLVER_H + +#include +#include + +#if defined(_WIN32) && defined(SECRETSPEC_RESOLVER_SHARED) +# if defined(SECRETSPEC_RESOLVER_BUILDING) +# define SECRETSPEC_RESOLVER_API __declspec(dllexport) +# else +# define SECRETSPEC_RESOLVER_API __declspec(dllimport) +# endif +#elif defined(__GNUC__) || defined(__clang__) +# define SECRETSPEC_RESOLVER_API __attribute__((visibility("default"))) +#else +# define SECRETSPEC_RESOLVER_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define SECRETSPEC_RESOLVER_ABI_VERSION ((1u << 16) | 0u) + +typedef struct secretspec_resolver_client secretspec_resolver_client; +typedef struct secretspec_resolver_call secretspec_resolver_call; +typedef struct secretspec_resolver_prompt secretspec_resolver_prompt; + +typedef struct { + const unsigned char *data; + size_t size; +} secretspec_resolver_slice; + +enum { + SECRETSPEC_RESOLVER_DISCOVER_EXECUTABLE = 1u << 0, + SECRETSPEC_RESOLVER_INHERIT_ENVIRONMENT = 1u << 1, + /* Advertise that this client can obtain a secret value from a person, so + * the endpoint may ask it to (0.20+). The library adds the capability to + * the initialization it sends; do not put client_methods in + * initialize_params_json yourself. + * + * A session with this flag answers prompts through + * secretspec_resolver_prompt_take and secretspec_resolver_prompt_answer, and its + * calls must be driven with secretspec_resolver_call_start and + * secretspec_resolver_call_wait rather than secretspec_resolver_client_call, which + * has no handle to resume after a prompt. */ + SECRETSPEC_RESOLVER_ANSWER_PROMPTS = 1u << 2 +}; + +typedef struct { + uint32_t struct_size; + uint32_t abi_version; + uint32_t flags; + uint32_t reserved; + secretspec_resolver_slice executable; + const secretspec_resolver_slice *arguments; + size_t argument_count; + const secretspec_resolver_slice *environment; + size_t environment_count; + secretspec_resolver_slice initialize_params_json; + size_t max_stderr_bytes; +} secretspec_resolver_options; + +typedef enum { + SECRETSPEC_RESOLVER_OK = 0, + SECRETSPEC_RESOLVER_INVALID_ARGUMENT = 1, + SECRETSPEC_RESOLVER_UNAVAILABLE = 2, + SECRETSPEC_RESOLVER_IO = 3, + SECRETSPEC_RESOLVER_PROTOCOL = 4, + SECRETSPEC_RESOLVER_REMOTE_ERROR = 5, + SECRETSPEC_RESOLVER_CANCELLED = 6, + SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED = 7, + /* A call cannot finish until a prompt is answered (0.20+). Take it with + * secretspec_resolver_prompt_take, answer or decline it, then wait again. Only + * a session opened with SECRETSPEC_RESOLVER_ANSWER_PROMPTS can see this. */ + SECRETSPEC_RESOLVER_PROMPT_PENDING = 8 +} secretspec_resolver_status; + +typedef struct { + unsigned char *data; + size_t size; +} secretspec_resolver_buffer; + +SECRETSPEC_RESOLVER_API uint32_t secretspec_resolver_abi_version(void); + +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_client_open( + const secretspec_resolver_options *options, + uint64_t deadline_unix_ms, + secretspec_resolver_client **client, + secretspec_resolver_buffer *server_info, + secretspec_resolver_buffer *error); + +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_call_start( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_call **call, + secretspec_resolver_buffer *error); + +/* Convenience form for callers that do not need cancellation or multiplexing. */ +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_client_call( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error); + +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_call_wait( + secretspec_resolver_call *call, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error); + +SECRETSPEC_RESOLVER_API void secretspec_resolver_call_cancel(secretspec_resolver_call *call); +SECRETSPEC_RESOLVER_API void secretspec_resolver_call_free(secretspec_resolver_call *call); + +/* Prompts (0.20+). + * + * The endpoint asks this client for a value only when the session advertised + * SECRETSPEC_RESOLVER_ANSWER_PROMPTS. There is deliberately no callback: a binding + * for another language must not have to hand a C function pointer to a foreign + * runtime, so the answer is driven by the caller instead. + * + * The loop is: + * + * status = secretspec_resolver_call_wait(call, &result, &error); + * while (status == SECRETSPEC_RESOLVER_PROMPT_PENDING) { + * secretspec_resolver_prompt *prompt = NULL; + * if (secretspec_resolver_prompt_take(client, &prompt, &error)) break; + * ... read a value from the person, using secretspec_resolver_prompt_params ... + * secretspec_resolver_prompt_answer(prompt, value, value_size, &error); + * secretspec_resolver_prompt_free(prompt); + * status = secretspec_resolver_call_wait(call, &result, &error); + * } + * + * A prompt belongs to the session, not to one call, so any waiting call may be + * the one that surfaces it. Every taken prompt must be answered or declined: + * one left unanswered blocks the endpoint until its deadline elapses. */ + +/* Take the prompt the endpoint is waiting on. Sets *prompt to NULL and returns + * OK when none is pending. */ +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_prompt_take( + secretspec_resolver_client *client, + secretspec_resolver_prompt **prompt, + secretspec_resolver_buffer *error); + +/* The prompt's parameters as JSON: the declared name, the profile, and the + * credential-free provider URI the answer will be stored at, if any. Borrowed + * from the prompt and valid until it is freed. */ +SECRETSPEC_RESOLVER_API secretspec_resolver_slice secretspec_resolver_prompt_params( + const secretspec_resolver_prompt *prompt); + +/* Answer with the value a person supplied. It is a secret: the library clears + * its own copy after writing, and the caller should clear the buffer it owns. + * An empty value is refused; decline instead. */ +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_prompt_answer( + secretspec_resolver_prompt *prompt, + const unsigned char *value, + size_t value_size, + secretspec_resolver_buffer *error); + +/* Refuse the prompt. The resolution that raised it fails as + * interaction_required rather than waiting out its deadline. */ +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_prompt_decline( + secretspec_resolver_prompt *prompt, + secretspec_resolver_buffer *error); + +SECRETSPEC_RESOLVER_API void secretspec_resolver_prompt_free(secretspec_resolver_prompt *prompt); + +SECRETSPEC_RESOLVER_API secretspec_resolver_status secretspec_resolver_client_close( + secretspec_resolver_client *client, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *error); + +SECRETSPEC_RESOLVER_API void secretspec_resolver_client_free(secretspec_resolver_client *client); +SECRETSPEC_RESOLVER_API void secretspec_resolver_buffer_free(secretspec_resolver_buffer buffer); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/libsecretspec-resolver/meson.build b/libsecretspec-resolver/meson.build new file mode 100644 index 000000000..e59e43264 --- /dev/null +++ b/libsecretspec-resolver/meson.build @@ -0,0 +1,122 @@ +project( + 'libsecretspec-resolver', + 'c', + version: '1.0.0', + meson_version: '>=1.3.0', + # Nix development environments define _FORTIFY_SOURCE, which requires an + # optimised build. Keep the strict warnings while making the default usable + # in those environments. + default_options: [ + 'buildtype=debugoptimized', + 'c_std=c11', + 'warning_level=3', + 'werror=true', + ], +) + +threads = dependency('threads') +yyjson = dependency('yyjson') +inc = include_directories('include', 'src') +platform_source = host_machine.system() == 'windows' ? 'src/process_windows.c' : 'src/process_posix.c' +sources = files( + 'src/frame.c', + 'src/json.c', + 'src/secure_memory.c', + 'src/session.c', + platform_source, +) + +cc = meson.get_compiler('c') +common_c_args = ['-DSECRETSPEC_RESOLVER_BUILDING'] +# yyjson arrives as a static archive whose symbols carry default visibility, so +# linking it into the shared library would re-export roughly fifty yyjson +# symbols and let an application's own copy of yyjson interpose on the one this +# library calls. Keep them local, matching what the previously vendored build +# achieved by compiling yyjson with hidden visibility. Windows needs nothing: +# there only SECRETSPEC_RESOLVER_API marks a symbol dllexport. +hide_yyjson_args = [] +foreach candidate : ['-Wl,--exclude-libs,ALL', '-Wl,-unexported_symbol,_yyjson*'] + if hide_yyjson_args.length() == 0 and cc.has_link_argument(candidate) + hide_yyjson_args += candidate + endif +endforeach +# MSVC gates behind an opt-in switch even in C11 mode. +if cc.get_id() == 'msvc' + common_c_args += '/experimental:c11atomics' +endif + +resolver_lib = library( + 'secretspec-resolver', + sources, + include_directories: inc, + dependencies: [threads, yyjson], + c_args: common_c_args, + c_shared_args: ['-DSECRETSPEC_RESOLVER_SHARED'], + link_args: hide_yyjson_args, + gnu_symbol_visibility: 'hidden', + soversion: '1', + install: true, +) + +install_headers('include/secretspec_resolver.h') + +pkgconfig = import('pkgconfig') +pkgconfig.generate( + resolver_lib, + filebase: 'secretspec-resolver', + name: 'libsecretspec-resolver', + description: 'SecretSpec local IPC C client', + version: meson.project_version(), + requires_private: ['yyjson'], +) + +smoke = executable( + 'secretspec_resolver_smoke', + 'tests/smoke.c', + include_directories: inc, + link_with: resolver_lib, + dependencies: threads, +) +test('secretspec_resolver_smoke', smoke) + +smoke_shared = executable( + 'secretspec_resolver_smoke_shared', + 'tests/smoke.c', + include_directories: inc, + link_with: resolver_lib, + dependencies: threads, +) +test('secretspec_resolver_smoke_shared', smoke_shared) + +fake_peer = executable( + 'secretspec_resolver_fake_peer', + 'tests/fake_peer.c', + include_directories: inc, + dependencies: yyjson, +) +session_test = executable( + 'secretspec_resolver_session', + 'tests/session.c', + include_directories: inc, + link_with: resolver_lib, + dependencies: threads, +) +test('secretspec_resolver_session', session_test, args: [fake_peer.full_path()]) + +backpressure_test = executable( + 'secretspec_resolver_backpressure', + 'tests/backpressure.c', + include_directories: inc, + link_with: resolver_lib, + dependencies: threads, +) +test('secretspec_resolver_backpressure', backpressure_test, args: [fake_peer.full_path()]) + +regressions_test = executable( + 'secretspec_resolver_regressions', + 'tests/regressions.c', + include_directories: inc, + link_with: resolver_lib, + dependencies: threads, +) +test('secretspec_resolver_regressions', regressions_test, args: [fake_peer.full_path()]) diff --git a/libsecretspec-resolver/secretspec-resolver.pc.in b/libsecretspec-resolver/secretspec-resolver.pc.in new file mode 100644 index 000000000..7fbbe339d --- /dev/null +++ b/libsecretspec-resolver/secretspec-resolver.pc.in @@ -0,0 +1,11 @@ +prefix=@CMAKE_INSTALL_PREFIX@ +exec_prefix=${prefix} +libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ + +Name: libsecretspec-resolver +Description: SecretSpec local IPC C client +Version: @PROJECT_VERSION@ +Requires.private: yyjson +Libs: -L${libdir} -lsecretspec-resolver +Cflags: -I${includedir} diff --git a/libsecretspec-resolver/src/frame.c b/libsecretspec-resolver/src/frame.c new file mode 100644 index 000000000..afdc8a5b2 --- /dev/null +++ b/libsecretspec-resolver/src/frame.c @@ -0,0 +1,112 @@ +#include "internal.h" + +#include +#include + +static bool looks_like_non_protocol_text(const secretspec_resolver_buffer *payload) { + size_t index = 0; + while (index < payload->size && + (payload->data[index] == ' ' || payload->data[index] == '\t')) index++; + if (index == payload->size || payload->data[index] == '{') return false; + for (; index < payload->size; index++) { + unsigned char byte = payload->data[index]; + if (byte != '\t' && (byte < 0x20 || byte > 0x7e)) return false; + } + return true; +} + +static secretspec_resolver_status frame_failure( + secretspec_resolver_buffer *payload, + bool *non_protocol_text, + secretspec_resolver_status status) { + if (non_protocol_text != NULL) { + *non_protocol_text = looks_like_non_protocol_text(payload); + } + secretspec_resolver_buffer_free(*payload); + ss_buffer_reset(payload); + return status; +} + +/* One UTF-8 JSON object per LF. The payload limit excludes that delimiter; + * buffering never exceeds it, even when a peer never sends LF. */ +secretspec_resolver_status ss_frame_read( + ss_process *process, + ss_frame_reader *reader, + size_t limit, + secretspec_resolver_buffer *payload, + bool *clean_eof, + bool *non_protocol_text) { + ss_buffer_reset(payload); + if (clean_eof != NULL) *clean_eof = false; + if (non_protocol_text != NULL) *non_protocol_text = false; + if (process == NULL || reader == NULL || limit == 0 || + limit > SS_ABSOLUTE_MAX_FRAME) { + return SECRETSPEC_RESOLVER_PROTOCOL; + } + payload->data = (unsigned char *)malloc(limit); + if (payload->data == NULL) return SECRETSPEC_RESOLVER_UNAVAILABLE; + payload->size = 0; + for (;;) { + unsigned char *newline; + size_t available; + size_t copied; + size_t consumed; + if (reader->start == reader->end) { + ptrdiff_t count = ss_process_read_stdout( + process, reader->data, sizeof(reader->data)); + reader->start = 0; + reader->end = count > 0 ? (size_t)count : 0; + if (count < 0) { + return frame_failure(payload, non_protocol_text, + SECRETSPEC_RESOLVER_IO); + } + if (count == 0) { + bool empty = payload->size == 0; + if (empty && clean_eof != NULL) *clean_eof = true; + return frame_failure( + payload, non_protocol_text, + empty ? SECRETSPEC_RESOLVER_OK : SECRETSPEC_RESOLVER_PROTOCOL); + } + } + + available = reader->end - reader->start; + newline = (unsigned char *)memchr(reader->data + reader->start, '\n', available); + copied = newline == NULL ? available : (size_t)(newline - (reader->data + reader->start)); + consumed = copied + (newline == NULL ? 0 : 1); + if (memchr(reader->data + reader->start, '\r', copied) != NULL || + copied > limit - payload->size) { + return frame_failure(payload, non_protocol_text, + SECRETSPEC_RESOLVER_PROTOCOL); + } + memcpy(payload->data + payload->size, reader->data + reader->start, copied); + payload->size += copied; + ss_secure_clear(reader->data + reader->start, consumed); + reader->start += consumed; + if (reader->start == reader->end) reader->start = reader->end = 0; + + if (newline != NULL) { + if (payload->size == 0) { + return frame_failure(payload, non_protocol_text, + SECRETSPEC_RESOLVER_PROTOCOL); + } + if (looks_like_non_protocol_text(payload)) { + if (non_protocol_text != NULL) *non_protocol_text = true; + secretspec_resolver_buffer_free(*payload); + ss_buffer_reset(payload); + return SECRETSPEC_RESOLVER_PROTOCOL; + } + return SECRETSPEC_RESOLVER_OK; + } + } +} + +bool ss_frame_write(ss_process *process, const unsigned char *payload, size_t size, size_t limit) { + static const unsigned char newline = '\n'; + if (process == NULL || payload == NULL || size == 0 || size > limit || + size > SS_ABSOLUTE_MAX_FRAME) return false; + for (size_t index = 0; index < size; index++) { + if (payload[index] == '\n' || payload[index] == '\r') return false; + } + return ss_process_write_stdin(process, payload, size) && + ss_process_write_stdin(process, &newline, 1); +} diff --git a/libsecretspec-resolver/src/internal.h b/libsecretspec-resolver/src/internal.h new file mode 100644 index 000000000..5e148dbae --- /dev/null +++ b/libsecretspec-resolver/src/internal.h @@ -0,0 +1,71 @@ +#ifndef SECRETSPEC_RESOLVER_INTERNAL_H +#define SECRETSPEC_RESOLVER_INTERNAL_H + +#include "secretspec_resolver.h" +#include "yyjson.h" + +#include +#include +#include + +#define SS_ABSOLUTE_MAX_FRAME ((size_t)1048576) +#define SS_MIN_FRAME ((size_t)4096) +#define SS_MAX_IN_FLIGHT ((size_t)32) +#define SS_MAX_ID UINT64_C(9007199254740991) +/* Largest interval a caller-supplied deadline may place in the future. Mirrors + * MAX_DEADLINE_HORIZON in the Rust implementation. Without it a deadline near + * UINT64_MAX makes every timed wait unrepresentable and requests never expire. */ +#define SS_MAX_DEADLINE_HORIZON_MS UINT64_C(300000) +#define SS_KNOWN_FLAGS (SECRETSPEC_RESOLVER_DISCOVER_EXECUTABLE | SECRETSPEC_RESOLVER_INHERIT_ENVIRONMENT | \ + SECRETSPEC_RESOLVER_ANSWER_PROMPTS) + +typedef struct ss_process ss_process; + +#define SS_FRAME_READ_CHUNK ((size_t)8192) + +typedef struct { + unsigned char data[SS_FRAME_READ_CHUNK]; + size_t start; + size_t end; +} ss_frame_reader; + +typedef struct { + char *executable; + char **arguments; + size_t argument_count; + char **environment; + size_t environment_count; + bool discover; + bool inherit_environment; +} ss_launch; + +secretspec_resolver_status ss_process_spawn(const ss_launch *launch, ss_process **process); +ptrdiff_t ss_process_read_stdout(ss_process *process, unsigned char *buffer, size_t size); +ptrdiff_t ss_process_read_stderr(ss_process *process, unsigned char *buffer, size_t size); +bool ss_process_write_stdin(ss_process *process, const unsigned char *buffer, size_t size); +void ss_process_close_stdin(ss_process *process); +void ss_process_interrupt_io(ss_process *process); +bool ss_process_wait(ss_process *process, uint64_t deadline_unix_ms); +void ss_process_terminate(ss_process *process); +void ss_process_free(ss_process *process); + +uint64_t ss_now_unix_ms(void); +void ss_secure_clear(void *pointer, size_t size); +void ss_buffer_reset(secretspec_resolver_buffer *buffer); +bool ss_buffer_copy(secretspec_resolver_buffer *buffer, const unsigned char *data, size_t size); +void ss_set_error(secretspec_resolver_buffer *error, const char *kind, const char *message); + +bool ss_json_validate(const unsigned char *json, size_t size, yyjson_doc **document); +bool ss_json_is_closed_object(yyjson_val *object, const char *const *keys, size_t key_count); +bool ss_json_u64(yyjson_val *value, uint64_t *number); +bool ss_json_write_value(yyjson_val *value, secretspec_resolver_buffer *buffer); +secretspec_resolver_status ss_frame_read( + ss_process *process, + ss_frame_reader *reader, + size_t limit, + secretspec_resolver_buffer *payload, + bool *clean_eof, + bool *non_protocol_text); +bool ss_frame_write(ss_process *process, const unsigned char *payload, size_t size, size_t limit); + +#endif diff --git a/libsecretspec-resolver/src/json.c b/libsecretspec-resolver/src/json.c new file mode 100644 index 000000000..83d8010b0 --- /dev/null +++ b/libsecretspec-resolver/src/json.c @@ -0,0 +1,123 @@ +#include "internal.h" + +#include +#include + +typedef struct { + const char *data; + size_t size; +} ss_key; + +static int ss_key_compare(const void *left_pointer, const void *right_pointer) { + const ss_key *left = (const ss_key *)left_pointer; + const ss_key *right = (const ss_key *)right_pointer; + size_t common = left->size < right->size ? left->size : right->size; + int order = memcmp(left->data, right->data, common); + if (order != 0) return order; + return left->size < right->size ? -1 : left->size > right->size; +} + +static bool ss_json_tree_valid(yyjson_val *value, size_t depth) { + yyjson_val *child; + size_t index; + size_t maximum; + + if (yyjson_is_arr(value)) { + if (depth >= 64) return false; + yyjson_arr_foreach(value, index, maximum, child) { + if (!ss_json_tree_valid(child, depth + 1)) return false; + } + return true; + } + if (yyjson_is_obj(value)) { + yyjson_obj_iter iterator; + yyjson_val *key; + size_t count; + size_t position = 0; + ss_key *keys; + bool valid = true; + if (depth >= 64) return false; + count = yyjson_obj_size(value); + keys = count == 0 ? NULL : (ss_key *)calloc(count, sizeof(*keys)); + if (count != 0 && keys == NULL) return false; + yyjson_obj_iter_init(value, &iterator); + while ((key = yyjson_obj_iter_next(&iterator)) != NULL) { + yyjson_val *member = yyjson_obj_iter_get_val(key); + keys[position].data = yyjson_get_str(key); + keys[position].size = yyjson_get_len(key); + position++; + if (!ss_json_tree_valid(member, depth + 1)) valid = false; + } + if (valid && count > 1) { + qsort(keys, count, sizeof(*keys), ss_key_compare); + for (position = 1; position < count; position++) { + if (keys[position - 1].size == keys[position].size && + memcmp(keys[position - 1].data, keys[position].data, keys[position].size) == 0) { + valid = false; + break; + } + } + } + free(keys); + return valid; + } + return true; +} + +bool ss_json_validate(const unsigned char *json, size_t size, yyjson_doc **document) { + yyjson_read_err error; + yyjson_doc *parsed; + yyjson_val *root; + if (document == NULL || json == NULL || size == 0) return false; + *document = NULL; + parsed = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error); + if (parsed == NULL) return false; + root = yyjson_doc_get_root(parsed); + if (!yyjson_is_obj(root) || !ss_json_tree_valid(root, 0)) { + yyjson_doc_free(parsed); + return false; + } + *document = parsed; + return true; +} + +bool ss_json_is_closed_object(yyjson_val *object, const char *const *keys, size_t key_count) { + yyjson_obj_iter iterator; + yyjson_val *key; + if (!yyjson_is_obj(object)) return false; + yyjson_obj_iter_init(object, &iterator); + while ((key = yyjson_obj_iter_next(&iterator)) != NULL) { + const char *name = yyjson_get_str(key); + size_t size = yyjson_get_len(key); + size_t index; + bool known = false; + for (index = 0; index < key_count; index++) { + if (strlen(keys[index]) == size && memcmp(keys[index], name, size) == 0) { + known = true; + break; + } + } + if (!known) return false; + } + return true; +} + +bool ss_json_u64(yyjson_val *value, uint64_t *number) { + if (!yyjson_is_uint(value) || number == NULL) return false; + *number = yyjson_get_uint(value); + return true; +} + +bool ss_json_write_value(yyjson_val *value, secretspec_resolver_buffer *buffer) { + yyjson_write_err error; + size_t size = 0; + char *json; + bool copied; + if (value == NULL || buffer == NULL) return false; + json = yyjson_val_write_opts((yyjson_val *)value, YYJSON_WRITE_NOFLAG, NULL, &size, &error); + if (json == NULL) return false; + copied = ss_buffer_copy(buffer, (const unsigned char *)json, size); + ss_secure_clear(json, size); + free(json); + return copied; +} diff --git a/libsecretspec-resolver/src/process_posix.c b/libsecretspec-resolver/src/process_posix.c new file mode 100644 index 000000000..9df8be1c5 --- /dev/null +++ b/libsecretspec-resolver/src/process_posix.c @@ -0,0 +1,299 @@ +#ifndef _WIN32 + +#define _POSIX_C_SOURCE 200809L +#include "internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +struct ss_process { + pid_t pid; + int input; + int output; + int error; + bool reaped; + atomic_bool interrupted; +}; + +static void close_fd(int *descriptor) { + if (*descriptor >= 0) { + (void)close(*descriptor); + *descriptor = -1; + } +} + +static bool set_cloexec(int descriptor) { + int flags = fcntl(descriptor, F_GETFD); + return flags >= 0 && fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) == 0; +} + +static bool set_nonblocking(int descriptor) { + int flags = fcntl(descriptor, F_GETFL); + return flags >= 0 && fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static bool make_pipe(int descriptors[2]) { + if (pipe(descriptors) != 0) return false; + if (!set_cloexec(descriptors[0]) || !set_cloexec(descriptors[1])) { + close(descriptors[0]); + close(descriptors[1]); + return false; + } + return true; +} + +static size_t env_key_size(const char *entry) { + const char *equals = strchr(entry, '='); + return equals == NULL ? strlen(entry) : (size_t)(equals - entry); +} + +static bool same_env_key(const char *left, const char *right) { + size_t left_size = env_key_size(left); + size_t right_size = env_key_size(right); + return left_size == right_size && memcmp(left, right, left_size) == 0; +} + +static char **build_environment(const ss_launch *launch, bool *allocated) { + size_t inherited = 0; + size_t index; + size_t position = 0; + char **result; + *allocated = false; + if (!launch->inherit_environment) return launch->environment; + while (environ[inherited] != NULL) inherited++; + result = (char **)calloc(inherited + launch->environment_count + 1, sizeof(char *)); + if (result == NULL) return NULL; + *allocated = true; + for (index = 0; index < inherited; index++) { + size_t override_index; + bool overridden = false; + for (override_index = 0; override_index < launch->environment_count; override_index++) { + if (same_env_key(environ[index], launch->environment[override_index])) { + overridden = true; + break; + } + } + if (!overridden) result[position++] = environ[index]; + } + for (index = 0; index < launch->environment_count; index++) { + result[position++] = launch->environment[index]; + } + result[position] = NULL; + return result; +} + +secretspec_resolver_status ss_process_spawn(const ss_launch *launch, ss_process **process_out) { + int input[2] = {-1, -1}; + int output[2] = {-1, -1}; + int error[2] = {-1, -1}; + posix_spawn_file_actions_t actions; + bool actions_ready = false; + char **argv = NULL; + char **environment = NULL; + bool environment_allocated = false; + ss_process *process = NULL; + pid_t pid = -1; + int spawn_error; + size_t index; + + if (process_out == NULL || launch == NULL) return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + *process_out = NULL; + if (!make_pipe(input) || !make_pipe(output) || !make_pipe(error)) goto io_error; + argv = (char **)calloc(launch->argument_count + 2, sizeof(char *)); + if (argv == NULL) goto unavailable; + argv[0] = launch->executable; + for (index = 0; index < launch->argument_count; index++) argv[index + 1] = launch->arguments[index]; + environment = build_environment(launch, &environment_allocated); + if (environment == NULL) goto unavailable; + if (posix_spawn_file_actions_init(&actions) != 0) goto io_error; + actions_ready = true; + if (posix_spawn_file_actions_adddup2(&actions, input[0], STDIN_FILENO) != 0 || + posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO) != 0 || + posix_spawn_file_actions_adddup2(&actions, error[1], STDERR_FILENO) != 0 || + posix_spawn_file_actions_addclose(&actions, input[1]) != 0 || + posix_spawn_file_actions_addclose(&actions, output[0]) != 0 || + posix_spawn_file_actions_addclose(&actions, error[0]) != 0) goto io_error; + + if (launch->discover) { + spawn_error = posix_spawnp(&pid, launch->executable, &actions, NULL, argv, environment); + } else { + spawn_error = posix_spawn(&pid, launch->executable, &actions, NULL, argv, environment); + } + if (spawn_error != 0) goto io_error; + process = (ss_process *)calloc(1, sizeof(*process)); + if (process == NULL) { + (void)kill(pid, SIGKILL); + (void)waitpid(pid, NULL, 0); + goto unavailable; + } + process->pid = pid; + process->input = input[1]; + process->output = output[0]; + process->error = error[0]; + input[1] = -1; + output[0] = -1; + error[0] = -1; + atomic_init(&process->interrupted, false); + if (!set_nonblocking(process->input) || !set_nonblocking(process->output) || + !set_nonblocking(process->error)) { + ss_process_free(process); + process = NULL; + goto io_error; + } + close_fd(&input[0]); + close_fd(&output[1]); + close_fd(&error[1]); + posix_spawn_file_actions_destroy(&actions); + free(argv); + if (environment_allocated) free(environment); + *process_out = process; + return SECRETSPEC_RESOLVER_OK; + +unavailable: + spawn_error = SECRETSPEC_RESOLVER_UNAVAILABLE; + goto cleanup; +io_error: + spawn_error = SECRETSPEC_RESOLVER_IO; +cleanup: + close_fd(&input[0]); + close_fd(&input[1]); + close_fd(&output[0]); + close_fd(&output[1]); + close_fd(&error[0]); + close_fd(&error[1]); + if (actions_ready) posix_spawn_file_actions_destroy(&actions); + free(argv); + if (environment_allocated) free(environment); + return (secretspec_resolver_status)spawn_error; +} + +static ptrdiff_t read_interruptible( + ss_process *process, + int descriptor, + unsigned char *buffer, + size_t size) { + struct pollfd descriptor_state = {descriptor, POLLIN | POLLHUP, 0}; + while (!atomic_load(&process->interrupted)) { + int ready = poll(&descriptor_state, 1, 100); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0) return -1; + if (ready == 0) continue; + for (;;) { + ssize_t count = read(descriptor, buffer, size); + if (count < 0 && errno == EINTR) continue; + if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break; + return (ptrdiff_t)count; + } + } + return -1; +} + +ptrdiff_t ss_process_read_stdout(ss_process *process, unsigned char *buffer, size_t size) { + return process == NULL || process->output < 0 ? -1 : + read_interruptible(process, process->output, buffer, size); +} + +ptrdiff_t ss_process_read_stderr(ss_process *process, unsigned char *buffer, size_t size) { + return process == NULL || process->error < 0 ? -1 : + read_interruptible(process, process->error, buffer, size); +} + +bool ss_process_write_stdin(ss_process *process, const unsigned char *buffer, size_t size) { + size_t written = 0; + sigset_t blocked; + sigset_t previous; + bool mask_changed = false; + if (process == NULL || process->input < 0) return false; + sigemptyset(&blocked); + sigaddset(&blocked, SIGPIPE); + if (pthread_sigmask(SIG_BLOCK, &blocked, &previous) == 0) mask_changed = true; + while (written < size && !atomic_load(&process->interrupted)) { + ssize_t count = write(process->input, buffer + written, size - written); + if (count < 0 && errno == EINTR) continue; + if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + struct pollfd descriptor_state = {process->input, POLLOUT, 0}; + int ready; + do { + ready = poll(&descriptor_state, 1, 100); + } while (ready < 0 && errno == EINTR); + if (ready >= 0) continue; + } + if (count <= 0) { + if (mask_changed && !sigismember(&previous, SIGPIPE)) { + // Drain the SIGPIPE this write just raised, which is pending on + // this thread because we blocked it above. macOS has no + // sigtimedwait, and sigwait cannot block here: the signal is + // thread-directed, so no other thread can consume it first. + sigset_t pending; + if (sigpending(&pending) == 0 && sigismember(&pending, SIGPIPE)) { + int drained; + (void)sigwait(&blocked, &drained); + } + } + if (mask_changed) (void)pthread_sigmask(SIG_SETMASK, &previous, NULL); + return false; + } + written += (size_t)count; + } + if (mask_changed) (void)pthread_sigmask(SIG_SETMASK, &previous, NULL); + return written == size; +} + +void ss_process_close_stdin(ss_process *process) { + if (process != NULL) close_fd(&process->input); +} + +void ss_process_interrupt_io(ss_process *process) { + if (process != NULL) atomic_store(&process->interrupted, true); +} + +bool ss_process_wait(ss_process *process, uint64_t deadline_unix_ms) { + struct timespec pause = {0, 10000000}; + if (process == NULL || process->reaped) return true; + for (;;) { + pid_t result = waitpid(process->pid, NULL, WNOHANG); + if (result == process->pid || (result < 0 && errno == ECHILD)) { + process->reaped = true; + return true; + } + if (result < 0 && errno != EINTR) return false; + if (ss_now_unix_ms() >= deadline_unix_ms) return false; + (void)nanosleep(&pause, NULL); + } +} + +void ss_process_terminate(ss_process *process) { + uint64_t grace; + if (process == NULL || process->reaped) return; + (void)kill(process->pid, SIGTERM); + grace = ss_now_unix_ms() + UINT64_C(250); + if (!ss_process_wait(process, grace)) { + (void)kill(process->pid, SIGKILL); + (void)waitpid(process->pid, NULL, 0); + process->reaped = true; + } +} + +void ss_process_free(ss_process *process) { + if (process == NULL) return; + ss_process_close_stdin(process); + close_fd(&process->output); + close_fd(&process->error); + ss_process_terminate(process); + free(process); +} + +#endif diff --git a/libsecretspec-resolver/src/process_windows.c b/libsecretspec-resolver/src/process_windows.c new file mode 100644 index 000000000..fde355c19 --- /dev/null +++ b/libsecretspec-resolver/src/process_windows.c @@ -0,0 +1,365 @@ +#ifdef _WIN32 + +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif +#define WIN32_LEAN_AND_MEAN +#include "internal.h" + +#include +#include +#include +#include + +struct ss_process { + HANDLE process; + HANDLE input; + HANDLE output; + HANDLE error; + bool reaped; +}; + +static wchar_t *utf8_to_wide(const char *text) { + int count; + wchar_t *wide; + if (text == NULL) return NULL; + count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, NULL, 0); + if (count <= 0) return NULL; + wide = (wchar_t *)calloc((size_t)count, sizeof(wchar_t)); + if (wide == NULL) return NULL; + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, wide, count) != count) { + free(wide); + return NULL; + } + return wide; +} + +static size_t quoted_size(const wchar_t *argument) { + size_t size = 2; + size_t slashes = 0; + const wchar_t *cursor; + for (cursor = argument; *cursor != L'\0'; cursor++) { + if (*cursor == L'\\') { + slashes++; + } else if (*cursor == L'\"') { + size += slashes * 2 + 2; + slashes = 0; + } else { + size += slashes + 1; + slashes = 0; + } + } + return size + slashes * 2 + 1; +} + +static wchar_t *append_quoted(wchar_t *output, const wchar_t *argument) { + size_t slashes = 0; + const wchar_t *cursor; + *output++ = L'\"'; + for (cursor = argument; *cursor != L'\0'; cursor++) { + if (*cursor == L'\\') { + slashes++; + continue; + } + if (*cursor == L'\"') { + while (slashes-- != 0) { *output++ = L'\\'; *output++ = L'\\'; } + *output++ = L'\\'; + *output++ = L'\"'; + } else { + while (slashes-- != 0) *output++ = L'\\'; + *output++ = *cursor; + } + slashes = 0; + } + while (slashes-- != 0) { *output++ = L'\\'; *output++ = L'\\'; } + *output++ = L'\"'; + return output; +} + +static wchar_t *build_command_line(const ss_launch *launch) { + wchar_t **arguments; + wchar_t *line; + wchar_t *cursor; + size_t count = launch->argument_count + 1; + size_t total = 1; + size_t index; + arguments = (wchar_t **)calloc(count, sizeof(wchar_t *)); + if (arguments == NULL) return NULL; + arguments[0] = utf8_to_wide(launch->executable); + for (index = 0; index < launch->argument_count; index++) { + arguments[index + 1] = utf8_to_wide(launch->arguments[index]); + } + for (index = 0; index < count; index++) { + if (arguments[index] == NULL) goto failed; + total += quoted_size(arguments[index]) + 1; + } + line = (wchar_t *)calloc(total, sizeof(wchar_t)); + if (line == NULL) goto failed; + cursor = line; + for (index = 0; index < count; index++) { + if (index != 0) *cursor++ = L' '; + cursor = append_quoted(cursor, arguments[index]); + free(arguments[index]); + } + *cursor = L'\0'; + free(arguments); + return line; +failed: + for (index = 0; index < count; index++) free(arguments[index]); + free(arguments); + return NULL; +} + +static size_t wide_key_size(const wchar_t *entry) { + /* Windows may include hidden drive-current-directory entries such as + * `=C:=C:\\work`; their name ends at the second equals sign. */ + const wchar_t *equals = wcschr(entry + (entry[0] == L'=' ? 1 : 0), L'='); + return equals == NULL ? wcslen(entry) : (size_t)(equals - entry); +} + +static bool overridden(const wchar_t *entry, wchar_t **overrides, size_t count) { + size_t entry_size = wide_key_size(entry); + size_t index; + for (index = 0; index < count; index++) { + size_t other_size = wide_key_size(overrides[index]); + if (entry_size == other_size && _wcsnicmp(entry, overrides[index], entry_size) == 0) return true; + } + return false; +} + +static int compare_environment_entries(const void *left_pointer, const void *right_pointer) { + const wchar_t *left = *(const wchar_t * const *)left_pointer; + const wchar_t *right = *(const wchar_t * const *)right_pointer; + size_t left_size = wide_key_size(left); + size_t right_size = wide_key_size(right); + size_t common = left_size < right_size ? left_size : right_size; + int compared = _wcsnicmp(left, right, common); + if (compared != 0) return compared; + if (left_size < right_size) return -1; + if (left_size > right_size) return 1; + /* Duplicate names are not expected, but a full-entry tie-breaker keeps the + * qsort ordering deterministic if a caller supplies them. */ + return _wcsicmp(left, right); +} + +static wchar_t *build_environment(const ss_launch *launch) { + wchar_t **overrides; + wchar_t **entries = NULL; + wchar_t *inherited = NULL; + wchar_t *block; + wchar_t *cursor; + size_t total = 2; + size_t index; + size_t inherited_count = 0; + size_t entry_count = 0; + overrides = (wchar_t **)calloc(launch->environment_count, sizeof(wchar_t *)); + if (launch->environment_count != 0 && overrides == NULL) return NULL; + for (index = 0; index < launch->environment_count; index++) { + overrides[index] = utf8_to_wide(launch->environment[index]); + if (overrides[index] == NULL) goto failed; + total += wcslen(overrides[index]) + 1; + } + if (launch->inherit_environment) { + wchar_t *entry; + inherited = GetEnvironmentStringsW(); + if (inherited == NULL) goto failed; + for (entry = inherited; *entry != L'\0'; entry += wcslen(entry) + 1) inherited_count++; + } + entries = (wchar_t **)calloc(inherited_count + launch->environment_count, sizeof(wchar_t *)); + if (inherited_count + launch->environment_count != 0 && entries == NULL) goto failed; + if (inherited != NULL) { + wchar_t *entry; + for (entry = inherited; *entry != L'\0'; entry += wcslen(entry) + 1) { + if (overridden(entry, overrides, launch->environment_count)) continue; + entries[entry_count++] = entry; + total += wcslen(entry) + 1; + } + } + for (index = 0; index < launch->environment_count; index++) { + entries[entry_count++] = overrides[index]; + } + if (entry_count > 1) { + qsort(entries, entry_count, sizeof(wchar_t *), compare_environment_entries); + } + block = (wchar_t *)calloc(total, sizeof(wchar_t)); + if (block == NULL) goto failed; + cursor = block; + for (index = 0; index < entry_count; index++) { + size_t size = wcslen(entries[index]) + 1; + memcpy(cursor, entries[index], size * sizeof(wchar_t)); + cursor += size; + } + *cursor++ = L'\0'; + *cursor = L'\0'; + for (index = 0; index < launch->environment_count; index++) free(overrides[index]); + free(overrides); + free(entries); + if (inherited != NULL) FreeEnvironmentStringsW(inherited); + return block; +failed: + for (index = 0; index < launch->environment_count; index++) free(overrides[index]); + free(overrides); + free(entries); + if (inherited != NULL) FreeEnvironmentStringsW(inherited); + return NULL; +} + +secretspec_resolver_status ss_process_spawn(const ss_launch *launch, ss_process **process_out) { + SECURITY_ATTRIBUTES security = {sizeof(security), NULL, TRUE}; + HANDLE child_input = NULL, parent_input = NULL; + HANDLE parent_output = NULL, child_output = NULL; + HANDLE parent_error = NULL, child_error = NULL; + STARTUPINFOEXW startup = {0}; + PROCESS_INFORMATION information; + SIZE_T attribute_size = 0; + HANDLE inherited_handles[3]; + bool attribute_ready = false; + wchar_t *application = NULL; + wchar_t *command_line = NULL; + wchar_t *environment = NULL; + ss_process *process = NULL; + BOOL created; + if (process_out == NULL || launch == NULL) return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + *process_out = NULL; + if (!CreatePipe(&child_input, &parent_input, &security, 0) || + !CreatePipe(&parent_output, &child_output, &security, 0) || + !CreatePipe(&parent_error, &child_error, &security, 0)) goto failed; + if (!SetHandleInformation(parent_input, HANDLE_FLAG_INHERIT, 0) || + !SetHandleInformation(parent_output, HANDLE_FLAG_INHERIT, 0) || + !SetHandleInformation(parent_error, HANDLE_FLAG_INHERIT, 0)) goto failed; + command_line = build_command_line(launch); + environment = build_environment(launch); + if (!launch->discover) application = utf8_to_wide(launch->executable); + if (command_line == NULL || environment == NULL || (!launch->discover && application == NULL)) goto failed; + ZeroMemory(&startup, sizeof(startup)); + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_input; + startup.StartupInfo.hStdOutput = child_output; + startup.StartupInfo.hStdError = child_error; + inherited_handles[0] = child_input; + inherited_handles[1] = child_output; + inherited_handles[2] = child_error; + (void)InitializeProcThreadAttributeList(NULL, 1, 0, &attribute_size); + startup.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attribute_size); + if (startup.lpAttributeList == NULL) goto failed; + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &attribute_size)) + goto failed; + attribute_ready = true; + if (!UpdateProcThreadAttribute(startup.lpAttributeList, 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited_handles, sizeof(inherited_handles), + NULL, NULL)) goto failed; + ZeroMemory(&information, sizeof(information)); + created = CreateProcessW(application, command_line, NULL, NULL, TRUE, + CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | + EXTENDED_STARTUPINFO_PRESENT, + environment, NULL, &startup.StartupInfo, &information); + if (!created) goto failed; + CloseHandle(information.hThread); + CloseHandle(child_input); child_input = NULL; + CloseHandle(child_output); child_output = NULL; + CloseHandle(child_error); child_error = NULL; + process = (ss_process *)calloc(1, sizeof(*process)); + if (process == NULL) { + TerminateProcess(information.hProcess, 1); + CloseHandle(information.hProcess); + goto failed; + } + process->process = information.hProcess; + process->input = parent_input; + process->output = parent_output; + process->error = parent_error; + DeleteProcThreadAttributeList(startup.lpAttributeList); + free(startup.lpAttributeList); + free(application); free(command_line); free(environment); + *process_out = process; + return SECRETSPEC_RESOLVER_OK; +failed: + if (startup.lpAttributeList != NULL) { + if (attribute_ready) DeleteProcThreadAttributeList(startup.lpAttributeList); + free(startup.lpAttributeList); + } + if (child_input) CloseHandle(child_input); + if (parent_input) CloseHandle(parent_input); + if (parent_output) CloseHandle(parent_output); + if (child_output) CloseHandle(child_output); + if (parent_error) CloseHandle(parent_error); + if (child_error) CloseHandle(child_error); + free(application); free(command_line); free(environment); + return SECRETSPEC_RESOLVER_IO; +} + +static ptrdiff_t read_handle(HANDLE handle, unsigned char *buffer, size_t size) { + DWORD read_count = 0; + DWORD requested = size > MAXDWORD ? MAXDWORD : (DWORD)size; + if (!ReadFile(handle, buffer, requested, &read_count, NULL)) { + return GetLastError() == ERROR_BROKEN_PIPE ? 0 : -1; + } + return (ptrdiff_t)read_count; +} + +ptrdiff_t ss_process_read_stdout(ss_process *process, unsigned char *buffer, size_t size) { + return process == NULL ? -1 : read_handle(process->output, buffer, size); +} + +ptrdiff_t ss_process_read_stderr(ss_process *process, unsigned char *buffer, size_t size) { + return process == NULL ? -1 : read_handle(process->error, buffer, size); +} + +bool ss_process_write_stdin(ss_process *process, const unsigned char *buffer, size_t size) { + size_t written = 0; + while (process != NULL && written < size) { + DWORD count = 0; + DWORD requested = size - written > MAXDWORD ? MAXDWORD : (DWORD)(size - written); + if (!WriteFile(process->input, buffer + written, requested, &count, NULL) || count == 0) return false; + written += count; + } + return process != NULL; +} + +void ss_process_close_stdin(ss_process *process) { + if (process != NULL && process->input != NULL) { + CloseHandle(process->input); + process->input = NULL; + } +} + +void ss_process_interrupt_io(ss_process *process) { + (void)process; +} + +bool ss_process_wait(ss_process *process, uint64_t deadline_unix_ms) { + uint64_t now; + DWORD timeout; + DWORD result; + if (process == NULL || process->reaped) return true; + now = ss_now_unix_ms(); + timeout = deadline_unix_ms <= now ? 0 : + deadline_unix_ms - now > MAXDWORD ? MAXDWORD : (DWORD)(deadline_unix_ms - now); + result = WaitForSingleObject(process->process, timeout); + if (result == WAIT_OBJECT_0) { + process->reaped = true; + return true; + } + return false; +} + +void ss_process_terminate(ss_process *process) { + if (process == NULL || process->reaped) return; + (void)TerminateProcess(process->process, 1); + (void)WaitForSingleObject(process->process, 1000); + process->reaped = true; +} + +void ss_process_free(ss_process *process) { + if (process == NULL) return; + ss_process_close_stdin(process); + ss_process_terminate(process); + if (process->output) CloseHandle(process->output); + if (process->error) CloseHandle(process->error); + if (process->process) CloseHandle(process->process); + free(process); +} + +#endif diff --git a/libsecretspec-resolver/src/secure_memory.c b/libsecretspec-resolver/src/secure_memory.c new file mode 100644 index 000000000..0c0f34cff --- /dev/null +++ b/libsecretspec-resolver/src/secure_memory.c @@ -0,0 +1,75 @@ +#include "internal.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +uint64_t ss_now_unix_ms(void) { +#if defined(_WIN32) + FILETIME file_time; + ULARGE_INTEGER value; + GetSystemTimeAsFileTime(&file_time); + value.LowPart = file_time.dwLowDateTime; + value.HighPart = file_time.dwHighDateTime; + return (value.QuadPart - UINT64_C(116444736000000000)) / UINT64_C(10000); +#else + struct timeval time; + if (gettimeofday(&time, NULL) != 0) return 0; + return (uint64_t)time.tv_sec * UINT64_C(1000) + (uint64_t)time.tv_usec / UINT64_C(1000); +#endif +} + +void ss_secure_clear(void *pointer, size_t size) { + volatile unsigned char *bytes = (volatile unsigned char *)pointer; + while (size-- != 0) *bytes++ = 0; +} + +void ss_buffer_reset(secretspec_resolver_buffer *buffer) { + if (buffer != NULL) { + buffer->data = NULL; + buffer->size = 0; + } +} + +bool ss_buffer_copy(secretspec_resolver_buffer *buffer, const unsigned char *data, size_t size) { + unsigned char *copy; + if (buffer == NULL) return false; + ss_buffer_reset(buffer); + if (size == 0) return true; + copy = (unsigned char *)malloc(size); + if (copy == NULL) return false; + memcpy(copy, data, size); + buffer->data = copy; + buffer->size = size; + return true; +} + +void ss_set_error(secretspec_resolver_buffer *error, const char *kind, const char *message) { + char stable[256]; + int count; + if (error == NULL) return; + ss_buffer_reset(error); + count = snprintf(stable, sizeof(stable), + "{\"kind\":\"%s\",\"message\":\"%s\"}", kind, message); + if (count > 0 && (size_t)count < sizeof(stable)) { + (void)ss_buffer_copy(error, (const unsigned char *)stable, (size_t)count); + } +} + +void secretspec_resolver_buffer_free(secretspec_resolver_buffer buffer) { + if (buffer.data != NULL) { + ss_secure_clear(buffer.data, buffer.size); + free(buffer.data); + } +} + +uint32_t secretspec_resolver_abi_version(void) { + return SECRETSPEC_RESOLVER_ABI_VERSION; +} diff --git a/libsecretspec-resolver/src/session.c b/libsecretspec-resolver/src/session.c new file mode 100644 index 000000000..dca44d872 --- /dev/null +++ b/libsecretspec-resolver/src/session.c @@ -0,0 +1,1916 @@ +#include "internal.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif +#define WIN32_LEAN_AND_MEAN +#include +typedef CRITICAL_SECTION ss_mutex; +typedef CONDITION_VARIABLE ss_condition; +typedef HANDLE ss_thread; +typedef DWORD (WINAPI *ss_thread_function)(LPVOID); +static bool mutex_init(ss_mutex *mutex) { InitializeCriticalSection(mutex); return true; } +static void mutex_destroy(ss_mutex *mutex) { DeleteCriticalSection(mutex); } +static void mutex_lock(ss_mutex *mutex) { EnterCriticalSection(mutex); } +static void mutex_unlock(ss_mutex *mutex) { LeaveCriticalSection(mutex); } +static bool condition_init(ss_condition *condition) { InitializeConditionVariable(condition); return true; } +static void condition_destroy(ss_condition *condition) { (void)condition; } +static void condition_broadcast(ss_condition *condition) { WakeAllConditionVariable(condition); } +static bool condition_wait_until(ss_condition *condition, ss_mutex *mutex, uint64_t deadline) { + uint64_t now = ss_now_unix_ms(); + uint64_t ceiling = now + SS_MAX_DEADLINE_HORIZON_MS; + DWORD timeout; + /* Matches the POSIX branch: bound how long one wait can block. */ + if (deadline > ceiling) deadline = ceiling; + timeout = deadline <= now ? 0 : (DWORD)(deadline - now); + return SleepConditionVariableCS(condition, mutex, timeout) != 0; +} +static bool thread_start(ss_thread *thread, ss_thread_function function, void *context) { + *thread = CreateThread(NULL, 0, function, context, 0, NULL); + return *thread != NULL; +} +static void thread_interrupt(ss_thread thread) { (void)CancelSynchronousIo(thread); } +static void thread_join(ss_thread thread) { WaitForSingleObject(thread, INFINITE); CloseHandle(thread); } +#define SS_THREAD_RETURN DWORD WINAPI +#define SS_THREAD_END return 0 +#else +#include +#include +#include +typedef pthread_mutex_t ss_mutex; +typedef pthread_cond_t ss_condition; +typedef pthread_t ss_thread; +typedef void *(*ss_thread_function)(void *); +static bool mutex_init(ss_mutex *mutex) { return pthread_mutex_init(mutex, NULL) == 0; } +static void mutex_destroy(ss_mutex *mutex) { (void)pthread_mutex_destroy(mutex); } +static void mutex_lock(ss_mutex *mutex) { (void)pthread_mutex_lock(mutex); } +static void mutex_unlock(ss_mutex *mutex) { (void)pthread_mutex_unlock(mutex); } +static bool condition_init(ss_condition *condition) { return pthread_cond_init(condition, NULL) == 0; } +static void condition_destroy(ss_condition *condition) { (void)pthread_cond_destroy(condition); } +static void condition_broadcast(ss_condition *condition) { (void)pthread_cond_broadcast(condition); } +static bool condition_wait_until(ss_condition *condition, ss_mutex *mutex, uint64_t deadline) { + struct timespec time; + int outcome; + uint64_t ceiling = ss_now_unix_ms() + SS_MAX_DEADLINE_HORIZON_MS; + /* A far-future deadline would push tv_sec out of the range + * pthread_cond_timedwait accepts. It would then fail with EINVAL on every + * call, and callers that loop on this helper would spin at full CPU rather + * than wait. Clamping keeps every wait representable. */ + if (deadline > ceiling) deadline = ceiling; + time.tv_sec = (time_t)(deadline / UINT64_C(1000)); + time.tv_nsec = (long)((deadline % UINT64_C(1000)) * UINT64_C(1000000)); + outcome = pthread_cond_timedwait(condition, mutex, &time); + return outcome == 0; +} +static bool thread_start(ss_thread *thread, ss_thread_function function, void *context) { + return pthread_create(thread, NULL, function, context) == 0; +} +static void thread_interrupt(ss_thread thread) { (void)thread; } +static void thread_join(ss_thread thread) { (void)pthread_join(thread, NULL); } +#define SS_THREAD_RETURN void * +#define SS_THREAD_END return NULL +#endif + +typedef struct ss_request ss_request; +typedef struct ss_outbound ss_outbound; +typedef struct ss_prompt ss_prompt; + +/* One inbound client.prompt the endpoint is waiting on. Held by the session + * until a caller takes it, so a prompt that arrives while nothing is waiting is + * not lost and is not answered by the wrong thread. */ +struct ss_prompt { + struct secretspec_resolver_client *client; + uint64_t id; + uint64_t parent_request_id; + uint64_t deadline_unix_ms; + secretspec_resolver_buffer params; + bool answered; + ss_prompt *next; +}; + +struct ss_outbound { + secretspec_resolver_buffer payload; + size_t limit; + ss_outbound *next; +}; + +struct ss_request { + uint64_t id; + uint64_t deadline_unix_ms; + secretspec_resolver_status status; + secretspec_resolver_buffer result; + secretspec_resolver_buffer error; + ss_condition condition; + atomic_size_t references; + bool running; + bool abandoned; + bool waiter; + bool cancel_sent; + ss_request *next; +}; + +struct secretspec_resolver_call { + struct secretspec_resolver_client *client; + ss_request *request; +}; + +struct secretspec_resolver_client { + ss_process *process; + ss_frame_reader frame_reader; + ss_mutex mutex; + ss_mutex write_mutex; + ss_condition state_changed; + ss_condition write_ready; + ss_thread writer_thread; + ss_thread reader_thread; + ss_thread stderr_thread; + ss_thread deadline_thread; + bool writer_started; + bool reader_started; + bool stderr_started; + bool deadline_started; + bool writer_stopping; + bool initializing; + bool ready; + bool closing; + bool closed; + size_t max_frame_bytes; + size_t max_in_flight; + size_t in_flight; + size_t entry_count; + size_t max_stderr_bytes; + uint64_t next_id; + uint64_t last_callback_id; + ss_request *requests; + ss_outbound *write_head; + ss_outbound *write_tail; + size_t write_count; + char **capabilities; + size_t capability_count; + /* Set from SECRETSPEC_RESOLVER_ANSWER_PROMPTS. When false an inbound request is + * the protocol violation it has always been, because the endpoint was never + * told this client could answer one. */ + bool answer_prompts; + ss_prompt *prompts; + size_t prompt_count; + atomic_size_t references; + bool user_released; +}; + +static void request_release(ss_request *request) { + if (request != NULL && atomic_fetch_sub(&request->references, 1) == 1) { + secretspec_resolver_buffer_free(request->result); + secretspec_resolver_buffer_free(request->error); + condition_destroy(&request->condition); + ss_secure_clear(request, sizeof(*request)); + free(request); + } +} + +static ss_request *request_new(uint64_t id, uint64_t deadline) { + ss_request *request = (ss_request *)calloc(1, sizeof(*request)); + if (request == NULL) return NULL; + request->id = id; + request->deadline_unix_ms = deadline; + request->status = SECRETSPEC_RESOLVER_UNAVAILABLE; + request->running = true; + atomic_init(&request->references, 2); + if (!condition_init(&request->condition)) { + free(request); + return NULL; + } + return request; +} + +static ss_request *find_request(secretspec_resolver_client *client, uint64_t id) { + ss_request *request; + for (request = client->requests; request != NULL; request = request->next) { + if (request->id == id) return request; + } + return NULL; +} + +static void remove_request(secretspec_resolver_client *client, ss_request *request) { + ss_request **cursor = &client->requests; + while (*cursor != NULL) { + if (*cursor == request) { + *cursor = request->next; + request->next = NULL; + client->entry_count--; + return; + } + cursor = &(*cursor)->next; + } +} + +static bool valid_utf8(const unsigned char *data, size_t size) { + size_t index = 0; + while (index < size) { + unsigned char first = data[index++]; + uint32_t code; + uint32_t minimum; + size_t remaining; + if (first < 0x80) continue; + if ((first & 0xe0) == 0xc0) { code = first & 0x1f; remaining = 1; minimum = 0x80; } + else if ((first & 0xf0) == 0xe0) { code = first & 0x0f; remaining = 2; minimum = 0x800; } + else if ((first & 0xf8) == 0xf0) { code = first & 0x07; remaining = 3; minimum = 0x10000; } + else return false; + if (remaining > size - index) return false; + while (remaining-- != 0) { + unsigned char next = data[index++]; + if ((next & 0xc0) != 0x80) return false; + code = (code << 6) | (next & 0x3f); + } + if (code < minimum || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff) return false; + } + return true; +} + +static bool valid_slice(secretspec_resolver_slice slice, bool allow_empty) { + return (slice.size == 0 ? allow_empty : slice.data != NULL) && + (slice.data != NULL || slice.size == 0) && + (slice.size == 0 || (memchr(slice.data, 0, slice.size) == NULL && valid_utf8(slice.data, slice.size))); +} + +static char *copy_slice(secretspec_resolver_slice slice) { + char *copy = (char *)malloc(slice.size + 1); + if (copy == NULL) return NULL; + if (slice.size != 0) memcpy(copy, slice.data, slice.size); + copy[slice.size] = '\0'; + return copy; +} + +static bool absolute_executable(const char *path) { +#ifdef _WIN32 + return (strlen(path) >= 3 && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && (path[2] == '\\' || path[2] == '/')) || + (path[0] == '\\' && path[1] == '\\'); +#else + return path[0] == '/'; +#endif +} + +static bool environment_entry_valid(const char *entry) { + const char *equals = strchr(entry, '='); + return equals != NULL && equals != entry; +} + +static void launch_free(ss_launch *launch) { + size_t index; + if (launch == NULL) return; + free(launch->executable); + for (index = 0; index < launch->argument_count; index++) free(launch->arguments[index]); + for (index = 0; index < launch->environment_count; index++) { + if (launch->environment[index] != NULL) { + ss_secure_clear(launch->environment[index], strlen(launch->environment[index])); + free(launch->environment[index]); + } + } + free(launch->arguments); + free(launch->environment); + memset(launch, 0, sizeof(*launch)); +} + +static secretspec_resolver_status launch_from_options(const secretspec_resolver_options *options, ss_launch *launch) { + size_t index; + memset(launch, 0, sizeof(*launch)); + launch->discover = (options->flags & SECRETSPEC_RESOLVER_DISCOVER_EXECUTABLE) != 0; + launch->inherit_environment = (options->flags & SECRETSPEC_RESOLVER_INHERIT_ENVIRONMENT) != 0; + launch->executable = copy_slice(options->executable); + launch->argument_count = options->argument_count; + launch->environment_count = options->environment_count; + launch->arguments = (char **)calloc(launch->argument_count + 1, sizeof(char *)); + launch->environment = (char **)calloc(launch->environment_count + 1, sizeof(char *)); + if (launch->executable == NULL || launch->arguments == NULL || launch->environment == NULL) goto unavailable; + if (!launch->discover && !absolute_executable(launch->executable)) return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + for (index = 0; index < launch->argument_count; index++) { + launch->arguments[index] = copy_slice(options->arguments[index]); + if (launch->arguments[index] == NULL) goto unavailable; + } + for (index = 0; index < launch->environment_count; index++) { + launch->environment[index] = copy_slice(options->environment[index]); + if (launch->environment[index] == NULL) goto unavailable; + if (!environment_entry_valid(launch->environment[index])) return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + return SECRETSPEC_RESOLVER_OK; +unavailable: + return SECRETSPEC_RESOLVER_UNAVAILABLE; +} + +/* Queue while client->mutex is held. This lets callback answers validate their + * parent and enqueue atomically with respect to the reader making it terminal. */ +static bool write_payload_locked(secretspec_resolver_client *client, const unsigned char *payload, size_t size) { + ss_outbound *outbound; + size_t limit = client->max_frame_bytes; + if (client->closed) return false; + + if (size == 0 || size > limit) return false; + outbound = (ss_outbound *)calloc(1, sizeof(*outbound)); + if (outbound == NULL || !ss_buffer_copy(&outbound->payload, payload, size)) { + free(outbound); + return false; + } + outbound->limit = limit; + + mutex_lock(&client->write_mutex); + if (client->writer_stopping || client->write_count >= SS_MAX_IN_FLIGHT * 2 + 4) { + mutex_unlock(&client->write_mutex); + secretspec_resolver_buffer_free(outbound->payload); + free(outbound); + return false; + } + if (client->write_tail == NULL) { + client->write_head = outbound; + } else { + client->write_tail->next = outbound; + } + client->write_tail = outbound; + client->write_count++; + condition_broadcast(&client->write_ready); + mutex_unlock(&client->write_mutex); + return true; +} + +static bool write_payload(secretspec_resolver_client *client, const unsigned char *payload, size_t size) { + bool written; + mutex_lock(&client->mutex); + written = write_payload_locked(client, payload, size); + mutex_unlock(&client->mutex); + return written; +} + +static bool build_request( + uint64_t id, + const char *method, + size_t method_size, + uint64_t deadline, + yyjson_val *params, + secretspec_resolver_buffer *payload) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root; + yyjson_mut_val *meta; + yyjson_mut_val *copied; + char *json; + size_t size; + bool outcome; + if (document == NULL) return false; + root = yyjson_mut_obj(document); + copied = yyjson_val_mut_copy(document, (yyjson_val *)params); + meta = yyjson_mut_obj(document); + if (root == NULL || meta == NULL || copied == NULL || + !yyjson_mut_obj_add_str(document, root, "jsonrpc", "2.0") || + !yyjson_mut_obj_add_uint(document, root, "id", id) || + !yyjson_mut_obj_add_strncpy(document, root, "method", method, method_size) || + !yyjson_mut_obj_add_uint(document, meta, "deadline_unix_ms", deadline) || + !yyjson_mut_obj_add_val(document, root, "_meta", meta) || + !yyjson_mut_obj_add_val(document, root, "params", copied)) { + yyjson_mut_doc_free(document); + return false; + } + yyjson_mut_doc_set_root(document, root); + json = yyjson_mut_write(document, YYJSON_WRITE_NOFLAG, &size); + yyjson_mut_doc_free(document); + if (json == NULL) return false; + outcome = ss_buffer_copy(payload, (const unsigned char *)json, size); + ss_secure_clear(json, size); + free(json); + return outcome; +} + +static bool send_cancel(secretspec_resolver_client *client, uint64_t id) { + char payload[128]; + int size = snprintf(payload, sizeof(payload), + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.cancel\",\"params\":{\"id\":%llu}}", + (unsigned long long)id); + return size > 0 && (size_t)size < sizeof(payload) && + write_payload(client, (const unsigned char *)payload, (size_t)size); +} + +static secretspec_resolver_status start_request( + secretspec_resolver_client *client, + const char *method, + size_t method_size, + yyjson_val *params, + uint64_t deadline, + bool application, + secretspec_resolver_call **call) { + uint64_t id; + ss_request *request; + secretspec_resolver_call *handle; + secretspec_resolver_buffer payload = {NULL, 0}; + uint64_t ceiling = ss_now_unix_ms() + SS_MAX_DEADLINE_HORIZON_MS; + /* Bound the tracked deadline so the request is guaranteed to expire and + * release its in-flight slot. The wire value carries the same clamp so the + * peer never enforces a longer deadline than this client tracks. */ + if (deadline > ceiling) deadline = ceiling; + mutex_lock(&client->mutex); + if (client->closed || (application && (!client->ready || client->closing))) { + mutex_unlock(&client->mutex); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + if (client->next_id == 0 || client->next_id > SS_MAX_ID || + client->in_flight >= client->max_in_flight || + client->entry_count >= client->max_in_flight * 2) { + mutex_unlock(&client->mutex); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + id = client->next_id++; + request = request_new(id, deadline); + handle = (secretspec_resolver_call *)calloc(1, sizeof(*handle)); + if (request == NULL || handle == NULL) { + if (request != NULL) { request_release(request); request_release(request); } + free(handle); + mutex_unlock(&client->mutex); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + request->next = client->requests; + client->requests = request; + client->in_flight++; + client->entry_count++; + handle->client = client; + handle->request = request; + (void)atomic_fetch_add(&client->references, 1); + condition_broadcast(&client->state_changed); + mutex_unlock(&client->mutex); + + if (!build_request(id, method, method_size, deadline, params, &payload) || + !write_payload(client, payload.data, payload.size)) { + secretspec_resolver_buffer_free(payload); + mutex_lock(&client->mutex); + if (request->running) { + request->running = false; + request->status = SECRETSPEC_RESOLVER_IO; + client->in_flight--; + remove_request(client, request); + condition_broadcast(&request->condition); + mutex_unlock(&client->mutex); + request_release(request); + } else { + mutex_unlock(&client->mutex); + } + *call = handle; + return SECRETSPEC_RESOLVER_IO; + } + secretspec_resolver_buffer_free(payload); + *call = handle; + return SECRETSPEC_RESOLVER_OK; +} + +static void prompts_clear(secretspec_resolver_client *client); + +/* Drop queued callbacks once the endpoint no longer accepts an answer. Must be + * called with client->mutex held. A taken prompt is guarded separately in + * answer_prompt because it is no longer part of this list. */ +static void prompts_expire(secretspec_resolver_client *client, uint64_t now_unix_ms) { + ss_prompt **cursor = &client->prompts; + while (*cursor != NULL) { + ss_prompt *prompt = *cursor; + if (prompt->deadline_unix_ms > now_unix_ms) { + cursor = &prompt->next; + continue; + } + *cursor = prompt->next; + client->prompt_count--; + secretspec_resolver_buffer_free(prompt->params); + ss_secure_clear(prompt, sizeof(*prompt)); + free(prompt); + } +} + +/* A callback is a child of exactly one active request. Remove queued children + * as soon as that parent is cancelled or terminal. A prompt already handed to + * the caller is checked again by answer_prompt. Must hold client->mutex. */ +static void prompts_cancel_parent(secretspec_resolver_client *client, uint64_t parent_request_id) { + ss_prompt **cursor = &client->prompts; + while (*cursor != NULL) { + ss_prompt *prompt = *cursor; + if (prompt->parent_request_id != parent_request_id) { + cursor = &prompt->next; + continue; + } + *cursor = prompt->next; + client->prompt_count--; + secretspec_resolver_buffer_free(prompt->params); + ss_secure_clear(prompt, sizeof(*prompt)); + free(prompt); + } +} + +static bool string_equals(yyjson_val *value, const char *expected) { + return yyjson_is_str(value) && yyjson_get_len(value) == strlen(expected) && + memcmp(yyjson_get_str(value), expected, yyjson_get_len(value)) == 0; +} + +static bool ignorable_notification(yyjson_val *root) { + static const char *const keys[] = {"jsonrpc", "method", "params"}; + yyjson_val *method = yyjson_obj_get(root, "method"); + size_t method_size = yyjson_is_str(method) ? yyjson_get_len(method) : 0; + return ss_json_is_closed_object(root, keys, 3) && + yyjson_obj_size(root) == 3 && + string_equals(yyjson_obj_get(root, "jsonrpc"), "2.0") && + method_size > 0 && method_size <= 256 && + yyjson_is_obj(yyjson_obj_get(root, "params")); +} + +static bool error_kind( + yyjson_val *error, + secretspec_resolver_status *status, + const char **kind_out) { + yyjson_val *code_value; + yyjson_val *message; + yyjson_val *data; + yyjson_val *kind; + yyjson_val *retryable; + yyjson_val *retry_after; + int64_t code; + const char *kind_text; + size_t kind_size; + struct mapping { int64_t code; const char *kind; secretspec_resolver_status status; }; + static const struct mapping mappings[] = { + {-32700, "parse_error", SECRETSPEC_RESOLVER_PROTOCOL}, + {-32600, "invalid_request", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32601, "method_not_found", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32602, "invalid_params", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32603, "internal", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32000, "unsupported_version", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32001, "capability_required", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32002, "deadline_exceeded", SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED}, + {-32003, "cancelled", SECRETSPEC_RESOLVER_CANCELLED}, + {-32004, "unavailable", SECRETSPEC_RESOLVER_UNAVAILABLE}, + {-32005, "permission_denied", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32006, "interaction_required", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32007, "conflict", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32008, "operation_failed", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32009, "message_too_large", SECRETSPEC_RESOLVER_REMOTE_ERROR}, + {-32010, "representation_mismatch", SECRETSPEC_RESOLVER_REMOTE_ERROR} + }; + size_t index; + bool code_is_defined = false; + code_value = yyjson_obj_get(error, "code"); + message = yyjson_obj_get(error, "message"); + data = yyjson_obj_get(error, "data"); + if (!yyjson_is_sint(code_value) || !yyjson_is_str(message) || + yyjson_get_len(message) == 0 || yyjson_get_len(message) > 256 || + !yyjson_is_obj(data)) return false; + code = yyjson_get_sint(code_value); + kind = yyjson_obj_get(data, "kind"); + retryable = yyjson_obj_get(data, "retryable"); + retry_after = yyjson_obj_get(data, "retry_after_ms"); + if (!yyjson_is_str(kind) || !yyjson_is_bool(retryable)) return false; + kind_text = yyjson_get_str(kind); + kind_size = yyjson_get_len(kind); + for (index = 0; index < sizeof(mappings) / sizeof(mappings[0]); index++) { + if (mappings[index].code != code) continue; + code_is_defined = true; + if (strlen(mappings[index].kind) == kind_size && + memcmp(mappings[index].kind, kind_text, kind_size) == 0) { + if (retry_after != NULL) { + uint64_t retry_after_ms; + if (code != -32004 || !ss_json_u64(retry_after, &retry_after_ms) || retry_after_ms == 0) return false; + } + *status = mappings[index].status; + *kind_out = mappings[index].kind; + return true; + } + } + /* A code this revision has never heard of, from a peer speaking a later + * revision. Refusing it would kill the session, and the error set could + * then never grow without a new protocol version, so it is reported as a + * generic remote failure instead. A code this revision *does* define must + * still arrive with the kind that belongs to it: that is a peer defect, not + * a version difference. */ + if (!code_is_defined) { + if (retry_after != NULL) { + uint64_t retry_after_ms; + if (!ss_json_u64(retry_after, &retry_after_ms) || retry_after_ms == 0) return false; + } + *status = SECRETSPEC_RESOLVER_REMOTE_ERROR; + *kind_out = "unrecognized"; + return true; + } + return false; +} + +static bool parse_response( + const unsigned char *payload, + size_t size, + uint64_t *id, + secretspec_resolver_status *status, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error_buffer) { + yyjson_doc *document = NULL; + yyjson_val *root; + yyjson_val *id_value; + yyjson_val *result_value; + yyjson_val *error_value; + const char *kind = NULL; + bool valid = false; + if (!ss_json_validate(payload, size, &document)) return false; + root = yyjson_doc_get_root(document); + if (!string_equals(yyjson_obj_get(root, "jsonrpc"), "2.0")) goto done; + id_value = yyjson_obj_get(root, "id"); + if (!ss_json_u64(id_value, id) || *id == 0 || *id > SS_MAX_ID) goto done; + result_value = yyjson_obj_get(root, "result"); + error_value = yyjson_obj_get(root, "error"); + if ((result_value == NULL) == (error_value == NULL)) goto done; + if (result_value != NULL) { + if (!ss_json_write_value(result_value, result)) goto done; + *status = SECRETSPEC_RESOLVER_OK; + } else { + if (!error_kind(error_value, status, &kind)) goto done; + ss_set_error(error_buffer, kind, "remote error"); + } + valid = true; +done: + yyjson_doc_free(document); + if (!valid) { + secretspec_resolver_buffer_free(*result); + secretspec_resolver_buffer_free(*error_buffer); + ss_buffer_reset(result); + ss_buffer_reset(error_buffer); + } + return valid; +} + +static void fail_all( + secretspec_resolver_client *client, + secretspec_resolver_status status, + const char *message) { + ss_request *request; + ss_request *next; + mutex_lock(&client->mutex); + if (client->closed) { + mutex_unlock(&client->mutex); + return; + } + client->closed = true; + client->ready = false; + /* A prompt nobody will now answer must not outlive the session that owed + * the endpoint a response for it. */ + prompts_clear(client); + request = client->requests; + client->requests = NULL; + client->entry_count = 0; + client->in_flight = 0; + while (request != NULL) { + next = request->next; + request->next = NULL; + if (request->running) { + request->running = false; + request->status = status; + ss_set_error(&request->error, + status == SECRETSPEC_RESOLVER_PROTOCOL ? "protocol" : "unavailable", + message != NULL + ? message + : (status == SECRETSPEC_RESOLVER_PROTOCOL ? "protocol error" + : "session closed")); + condition_broadcast(&request->condition); + } + request_release(request); + request = next; + } + condition_broadcast(&client->state_changed); + mutex_unlock(&client->mutex); + ss_process_interrupt_io(client->process); +} + +/* Accept one inbound request as a prompt. + * + * Returns false for anything this session must not accept: a request at all + * when no callback was advertised, a method other than the one that was, a + * malformed envelope, or more outstanding prompts than the negotiated in-flight + * bound allows. Each of those means the endpoint is not tracking the session + * state this side is, which is a protocol violation rather than a prompt. */ +static bool accept_prompt(secretspec_resolver_client *client, yyjson_val *root) { + static const char *const keys[] = {"jsonrpc", "id", "method", "_meta", "params"}; + static const char *const meta_keys[] = {"deadline_unix_ms", "parent_request_id"}; + yyjson_val *method; + yyjson_val *id_value; + yyjson_val *meta; + yyjson_val *deadline; + yyjson_val *parent; + yyjson_val *params; + ss_prompt *prompt; + uint64_t id; + uint64_t deadline_unix_ms; + if (!client->answer_prompts || !ss_json_is_closed_object(root, keys, 5)) return false; + method = yyjson_obj_get(root, "method"); + id_value = yyjson_obj_get(root, "id"); + meta = yyjson_obj_get(root, "_meta"); + deadline = meta == NULL ? NULL : yyjson_obj_get(meta, "deadline_unix_ms"); + parent = meta == NULL ? NULL : yyjson_obj_get(meta, "parent_request_id"); + params = yyjson_obj_get(root, "params"); + if (!string_equals(yyjson_obj_get(root, "jsonrpc"), "2.0") || + !string_equals(method, "client.prompt") || !ss_json_is_closed_object(meta, meta_keys, 2) || + !ss_json_u64(id_value, &id) || id == 0 || id > SS_MAX_ID || + !ss_json_u64(deadline, &deadline_unix_ms) || !yyjson_is_uint(parent) || !yyjson_is_obj(params)) return false; + + prompt = (ss_prompt *)calloc(1, sizeof(*prompt)); + if (prompt == NULL) return false; + if (!ss_json_write_value(params, &prompt->params)) { + free(prompt); + return false; + } + prompt->client = client; + prompt->id = id; + prompt->parent_request_id = yyjson_get_uint(parent); + prompt->deadline_unix_ms = deadline_unix_ms; + mutex_lock(&client->mutex); + { + ss_request *parent_request = find_request(client, prompt->parent_request_id); + if (parent_request == NULL || !parent_request->running || parent_request->cancel_sent || + deadline_unix_ms > parent_request->deadline_unix_ms) { + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(prompt->params); + ss_secure_clear(prompt, sizeof(*prompt)); + free(prompt); + return false; + } + } + prompts_expire(client, ss_now_unix_ms()); + if (deadline_unix_ms <= ss_now_unix_ms()) { + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(prompt->params); + ss_secure_clear(prompt, sizeof(*prompt)); + free(prompt); + return true; + } + if (client->closed || id <= client->last_callback_id || + client->prompt_count >= client->max_in_flight) { + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(prompt->params); + free(prompt); + return false; + } + client->last_callback_id = id; + prompt->next = client->prompts; + client->prompts = prompt; + client->prompt_count++; + /* Wake every waiting call: a prompt blocks whichever one raised it, and the + * caller cannot know which that was. */ + condition_broadcast(&client->state_changed); + { + ss_request *request; + for (request = client->requests; request != NULL; request = request->next) { + condition_broadcast(&request->condition); + } + } + mutex_unlock(&client->mutex); + return true; +} + +static void prompts_clear(secretspec_resolver_client *client) { + ss_prompt *prompt = client->prompts; + client->prompts = NULL; + client->prompt_count = 0; + while (prompt != NULL) { + ss_prompt *next = prompt->next; + secretspec_resolver_buffer_free(prompt->params); + ss_secure_clear(prompt, sizeof(*prompt)); + free(prompt); + prompt = next; + } +} + +static void outbound_free(ss_outbound *outbound) { + if (outbound == NULL) return; + secretspec_resolver_buffer_free(outbound->payload); + ss_secure_clear(outbound, sizeof(*outbound)); + free(outbound); +} + +static SS_THREAD_RETURN writer_main(void *context) { + secretspec_resolver_client *client = (secretspec_resolver_client *)context; + for (;;) { + ss_outbound *outbound; + mutex_lock(&client->write_mutex); + while (client->write_head == NULL && !client->writer_stopping) { + (void)condition_wait_until(&client->write_ready, &client->write_mutex, + ss_now_unix_ms() + UINT64_C(1000)); + } + if (client->writer_stopping) { + outbound = client->write_head; + client->write_head = NULL; + client->write_tail = NULL; + client->write_count = 0; + mutex_unlock(&client->write_mutex); + while (outbound != NULL) { + ss_outbound *next = outbound->next; + outbound_free(outbound); + outbound = next; + } + break; + } + outbound = client->write_head; + client->write_head = outbound->next; + if (client->write_head == NULL) client->write_tail = NULL; + client->write_count--; + mutex_unlock(&client->write_mutex); + + if (!ss_frame_write(client->process, outbound->payload.data, + outbound->payload.size, outbound->limit)) { + outbound_free(outbound); + fail_all(client, SECRETSPEC_RESOLVER_IO, NULL); + break; + } + outbound_free(outbound); + } + SS_THREAD_END; +} + +static SS_THREAD_RETURN reader_main(void *context) { + secretspec_resolver_client *client = (secretspec_resolver_client *)context; + for (;;) { + size_t limit; + secretspec_resolver_buffer payload = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status frame_status; + secretspec_resolver_status response_status; + bool clean_eof = false; + bool non_protocol_text = false; + uint64_t id = 0; + ss_request *request; + bool release_list_reference = false; + + mutex_lock(&client->mutex); + if (client->closed) { mutex_unlock(&client->mutex); break; } + limit = client->max_frame_bytes; + mutex_unlock(&client->mutex); + frame_status = + ss_frame_read(client->process, &client->frame_reader, limit, &payload, + &clean_eof, &non_protocol_text); + if (frame_status != SECRETSPEC_RESOLVER_OK || clean_eof) { + fail_all(client, + clean_eof ? SECRETSPEC_RESOLVER_UNAVAILABLE : frame_status, + non_protocol_text + ? "peer wrote non-protocol text to the stream reserved for frames" + : NULL); + break; + } + if (!parse_response(payload.data, payload.size, &id, &response_status, &result, &error)) { + /* Not a response. The one other thing it may be is a prompt this + * session advertised it could answer; anything else is the protocol + * violation an inbound envelope has always been. */ + yyjson_doc *document = NULL; + bool prompted = false; + bool notification = false; + if (ss_json_validate(payload.data, payload.size, &document)) { + yyjson_val *root = yyjson_doc_get_root(document); + prompted = accept_prompt(client, root); + /* Notifications have no response path. Ignore structurally + * valid unknown methods and malformed cancellation params so + * a cancellation race cannot kill a useful resolver session. + * The envelope itself stays strict. */ + notification = ignorable_notification(root); + yyjson_doc_free(document); + } + secretspec_resolver_buffer_free(payload); + if (prompted || notification) continue; + fail_all(client, SECRETSPEC_RESOLVER_PROTOCOL, NULL); + break; + } + secretspec_resolver_buffer_free(payload); + + mutex_lock(&client->mutex); + request = find_request(client, id); + if (request == NULL) { + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + fail_all(client, SECRETSPEC_RESOLVER_PROTOCOL, NULL); + break; + } + if (request->abandoned) { + prompts_cancel_parent(client, id); + remove_request(client, request); + release_list_reference = true; + } else if (request->running) { + request->running = false; + prompts_cancel_parent(client, id); + request->status = response_status; + request->result = result; + request->error = error; + ss_buffer_reset(&result); + ss_buffer_reset(&error); + client->in_flight--; + remove_request(client, request); + release_list_reference = true; + condition_broadcast(&request->condition); + } else { + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + fail_all(client, SECRETSPEC_RESOLVER_PROTOCOL, NULL); + break; + } + if (id == 1) { + while (client->initializing && !client->closed) { + (void)condition_wait_until(&client->state_changed, &client->mutex, + ss_now_unix_ms() + UINT64_C(1000)); + } + } + mutex_unlock(&client->mutex); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (release_list_reference) request_release(request); + } + SS_THREAD_END; +} + +static SS_THREAD_RETURN stderr_main(void *context) { + secretspec_resolver_client *client = (secretspec_resolver_client *)context; + unsigned char buffer[4096]; + unsigned char *retained = NULL; + size_t retained_size = 0; + if (client->max_stderr_bytes != 0) { + retained = (unsigned char *)malloc(client->max_stderr_bytes); + } + for (;;) { + ptrdiff_t count = ss_process_read_stderr(client->process, buffer, sizeof(buffer)); + if (count <= 0) break; + if (retained != NULL && retained_size < client->max_stderr_bytes) { + size_t copy = (size_t)count; + if (copy > client->max_stderr_bytes - retained_size) copy = client->max_stderr_bytes - retained_size; + memcpy(retained + retained_size, buffer, copy); + retained_size += copy; + } + ss_secure_clear(buffer, sizeof(buffer)); + } + if (retained != NULL) { + ss_secure_clear(retained, client->max_stderr_bytes); + free(retained); + } + SS_THREAD_END; +} + +static SS_THREAD_RETURN deadline_main(void *context) { + secretspec_resolver_client *client = (secretspec_resolver_client *)context; + for (;;) { + uint64_t expired_ids[SS_MAX_IN_FLIGHT * 2]; + size_t expired_count = 0; + uint64_t wake_at = UINT64_MAX; + uint64_t now = ss_now_unix_ms(); + ss_request *request; + + mutex_lock(&client->mutex); + if (client->closed) { + mutex_unlock(&client->mutex); + break; + } + for (request = client->requests; request != NULL; request = request->next) { + if (!request->running) continue; + if (request->deadline_unix_ms <= now) { + request->running = false; + request->abandoned = true; + prompts_cancel_parent(client, request->id); + request->status = SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; + client->in_flight--; + ss_set_error(&request->error, "deadline_exceeded", "deadline exceeded"); + condition_broadcast(&request->condition); + if (!request->cancel_sent && expired_count < SS_MAX_IN_FLIGHT * 2) { + request->cancel_sent = true; + expired_ids[expired_count++] = request->id; + } + } else if (request->deadline_unix_ms < wake_at) { + wake_at = request->deadline_unix_ms; + } + } + if (expired_count == 0) { + if (wake_at == UINT64_MAX) wake_at = now + UINT64_C(1000); + (void)condition_wait_until(&client->state_changed, &client->mutex, wake_at); + } + mutex_unlock(&client->mutex); + + for (size_t index = 0; index < expired_count; index++) { + (void)send_cancel(client, expired_ids[index]); + } + } + SS_THREAD_END; +} + +static bool versions_contains(yyjson_val *array, uint64_t version) { + size_t index; + size_t maximum; + yyjson_val *item; + yyjson_arr_foreach(array, index, maximum, item) { + uint64_t value; + if (ss_json_u64(item, &value) && value == version) return true; + } + return false; +} + +static bool array_has_text(yyjson_val *array, const char *text) { + size_t index; + size_t maximum; + yyjson_val *item; + yyjson_arr_foreach(array, index, maximum, item) { + if (string_equals(item, text)) return true; + } + return false; +} + +static bool string_array_valid(yyjson_val *array, bool nonempty) { + size_t index; + size_t maximum; + yyjson_val *item; + if (!yyjson_is_arr(array) || (nonempty && yyjson_arr_size(array) == 0)) return false; + yyjson_arr_foreach(array, index, maximum, item) { + size_t earlier; + yyjson_val *other; + if (!yyjson_is_str(item) || yyjson_get_len(item) == 0 || yyjson_get_len(item) > 256) return false; + for (earlier = 0; earlier < index; earlier++) { + other = yyjson_arr_get(array, earlier); + if (yyjson_get_len(other) == yyjson_get_len(item) && + memcmp(yyjson_get_str(other), yyjson_get_str(item), yyjson_get_len(item)) == 0) return false; + } + } + return true; +} + +static bool product_valid(yyjson_val *product) { + static const char *const keys[] = {"name", "version"}; + yyjson_val *name; + yyjson_val *version; + if (!ss_json_is_closed_object(product, keys, 2)) return false; + name = yyjson_obj_get(product, "name"); + version = yyjson_obj_get(product, "version"); + return yyjson_is_str(name) && yyjson_get_len(name) > 0 && yyjson_get_len(name) <= 256 && + yyjson_is_str(version) && yyjson_get_len(version) > 0 && yyjson_get_len(version) <= 256; +} + +static bool limits_valid(yyjson_val *limits, size_t *frame, size_t *in_flight) { + static const char *const keys[] = {"max_frame_bytes", "max_in_flight"}; + uint64_t frame_value; + uint64_t in_flight_value; + if (!ss_json_is_closed_object(limits, keys, 2) || + !ss_json_u64(yyjson_obj_get(limits, "max_frame_bytes"), &frame_value) || + !ss_json_u64(yyjson_obj_get(limits, "max_in_flight"), &in_flight_value) || + frame_value < SS_MIN_FRAME || frame_value > SS_ABSOLUTE_MAX_FRAME || + in_flight_value == 0 || in_flight_value > SS_MAX_IN_FLIGHT) return false; + *frame = (size_t)frame_value; + *in_flight = (size_t)in_flight_value; + return true; +} + +/* Re-emit the caller's initialization offer with the prompt capability added. + * + * The offer is a small validated object, so it is rewritten as text and parsed + * back rather than threading a mutable document through the request path. */ +static bool offer_with_prompt_capability(yyjson_val *offer, yyjson_doc **out) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root; + yyjson_mut_val *capabilities; + char *json = NULL; + size_t size = 0; + bool built = false; + *out = NULL; + if (document == NULL) return false; + root = yyjson_val_mut_copy(document, offer); + capabilities = yyjson_mut_arr(document); + if (root != NULL && capabilities != NULL && + yyjson_mut_arr_add_str(document, capabilities, "client.prompt") && + yyjson_mut_obj_add_val(document, root, "client_methods", capabilities)) { + yyjson_mut_doc_set_root(document, root); + json = yyjson_mut_write(document, YYJSON_WRITE_NOFLAG, &size); + } + yyjson_mut_doc_free(document); + if (json == NULL) return false; + built = ss_json_validate((const unsigned char *)json, size, out); + free(json); + return built; +} + +static bool initialize_offer_valid(yyjson_val *offer) { + static const char *const keys[] = { + "protocol", "versions", "client", "limits", "application" + }; + yyjson_val *protocol; + yyjson_val *versions; + size_t index; + size_t maximum; + yyjson_val *version; + size_t frame; + size_t in_flight; + if (!ss_json_is_closed_object(offer, keys, 5)) return false; + protocol = yyjson_obj_get(offer, "protocol"); + versions = yyjson_obj_get(offer, "versions"); + /* Resolver only. The provider protocol's client is always the SecretSpec + * resolver, which is Rust, so a C client for it would serve nobody. */ + if (!string_equals(protocol, "secretspec.resolver") || + !yyjson_is_arr(versions) || yyjson_arr_size(versions) == 0 || + !product_valid(yyjson_obj_get(offer, "client")) || + !limits_valid(yyjson_obj_get(offer, "limits"), &frame, &in_flight) || + !yyjson_is_obj(yyjson_obj_get(offer, "application"))) return false; + yyjson_arr_foreach(versions, index, maximum, version) { + uint64_t value; + size_t earlier; + if (!ss_json_u64(version, &value) || value == 0 || value > UINT32_MAX) return false; + for (earlier = 0; earlier < index; earlier++) { + if (yyjson_get_uint(yyjson_arr_get(versions, earlier)) == value) return false; + } + } + return true; +} + +static void capabilities_clear(secretspec_resolver_client *client) { + size_t index; + for (index = 0; index < client->capability_count; index++) free(client->capabilities[index]); + free(client->capabilities); + client->capabilities = NULL; + client->capability_count = 0; +} + +static bool validate_initialize_result( + secretspec_resolver_client *client, + yyjson_val *offer, + const unsigned char *json, + size_t json_size) { + static const char *const keys[] = { + "protocol", "version", "server", "methods", "capabilities", "limits", "application" + }; + yyjson_doc *document = NULL; + yyjson_val *result; + yyjson_val *protocol; + yyjson_val *version; + yyjson_val *capabilities; + yyjson_val *offered_versions; + uint64_t selected_version; + size_t frame; + size_t in_flight; + size_t offered_frame; + size_t offered_in_flight; + size_t index; + size_t maximum; + yyjson_val *capability; + bool valid = false; + if (!ss_json_validate(json, json_size, &document)) return false; + result = yyjson_doc_get_root(document); + protocol = yyjson_obj_get(result, "protocol"); + version = yyjson_obj_get(result, "version"); + capabilities = yyjson_obj_get(result, "methods"); + offered_versions = yyjson_obj_get(offer, "versions"); + if (!ss_json_is_closed_object(result, keys, 7) || + !yyjson_equals_strn(protocol, yyjson_get_str(yyjson_obj_get(offer, "protocol")), + yyjson_get_len(yyjson_obj_get(offer, "protocol"))) || + !ss_json_u64(version, &selected_version) || + !versions_contains(offered_versions, selected_version) || + !product_valid(yyjson_obj_get(result, "server")) || + !string_array_valid(capabilities, true) || !yyjson_is_obj(yyjson_obj_get(result, "capabilities")) || + !limits_valid(yyjson_obj_get(result, "limits"), &frame, &in_flight) || + !limits_valid(yyjson_obj_get(offer, "limits"), &offered_frame, &offered_in_flight) || + frame > offered_frame || in_flight > offered_in_flight || + !yyjson_is_obj(yyjson_obj_get(result, "application"))) goto done; + if (!array_has_text(capabilities, "resolver.get") || + !array_has_text(capabilities, "resolver.release")) goto done; + client->capabilities = (char **)calloc(yyjson_arr_size(capabilities), sizeof(char *)); + if (client->capabilities == NULL) goto done; + yyjson_arr_foreach(capabilities, index, maximum, capability) { + size_t size = yyjson_get_len(capability); + client->capabilities[index] = (char *)malloc(size + 1); + if (client->capabilities[index] == NULL) { + client->capability_count = index; + capabilities_clear(client); + goto done; + } + memcpy(client->capabilities[index], yyjson_get_str(capability), size); + client->capabilities[index][size] = '\0'; + } + client->capability_count = yyjson_arr_size(capabilities); + client->max_frame_bytes = frame; + client->max_in_flight = in_flight; + valid = true; +done: + yyjson_doc_free(document); + return valid; +} + +static bool method_advertised(secretspec_resolver_client *client, const char *method, size_t size) { + size_t index; + for (index = 0; index < client->capability_count; index++) { + if (strlen(client->capabilities[index]) == size && + memcmp(client->capabilities[index], method, size) == 0) return true; + } + return false; +} + +static secretspec_resolver_status wait_call( + secretspec_resolver_call *call, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error) { + secretspec_resolver_client *client = call->client; + ss_request *request = call->request; + secretspec_resolver_status status; + bool timed_out = false; + mutex_lock(&client->mutex); + if (request->waiter) { + mutex_unlock(&client->mutex); + ss_set_error(error, "invalid_argument", "call already has a waiter"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + request->waiter = true; + while (request->running) { + /* The endpoint is waiting on this client before it can finish any call, + * so hand the prompt back rather than blocking until the deadline. The + * waiter slot is released first: the caller answers and then waits + * again on the same call. */ + prompts_expire(client, ss_now_unix_ms()); + if (client->prompts != NULL) { + request->waiter = false; + mutex_unlock(&client->mutex); + return SECRETSPEC_RESOLVER_PROMPT_PENDING; + } + if (request->deadline_unix_ms <= ss_now_unix_ms() || + !condition_wait_until(&request->condition, &client->mutex, request->deadline_unix_ms)) { + if (request->running) { + request->running = false; + request->abandoned = true; + prompts_cancel_parent(client, request->id); + request->status = SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; + client->in_flight--; + ss_set_error(&request->error, "deadline_exceeded", "deadline exceeded"); + request->cancel_sent = true; + timed_out = true; + } + break; + } + } + status = request->status; + if (status == SECRETSPEC_RESOLVER_OK && result != NULL) { + *result = request->result; + ss_buffer_reset(&request->result); + } else if (status != SECRETSPEC_RESOLVER_OK && error != NULL) { + *error = request->error; + ss_buffer_reset(&request->error); + } + mutex_unlock(&client->mutex); + if (timed_out) (void)send_cancel(client, request->id); + return status; +} + +static void client_destroy(secretspec_resolver_client *client) { + if (client == NULL) return; + capabilities_clear(client); + condition_destroy(&client->write_ready); + condition_destroy(&client->state_changed); + mutex_destroy(&client->write_mutex); + mutex_destroy(&client->mutex); + ss_secure_clear(client, sizeof(*client)); + free(client); +} + +static void client_release(secretspec_resolver_client *client) { + if (atomic_fetch_sub(&client->references, 1) == 1) client_destroy(client); +} + +static void cleanup_process(secretspec_resolver_client *client, uint64_t deadline) { + ss_outbound *outbound; + uint64_t cap = ss_now_unix_ms() + UINT64_C(5000); + if (deadline > cap) deadline = cap; + mutex_lock(&client->write_mutex); + client->writer_stopping = true; + condition_broadcast(&client->write_ready); + mutex_unlock(&client->write_mutex); + fail_all(client, SECRETSPEC_RESOLVER_UNAVAILABLE, NULL); + ss_process_interrupt_io(client->process); + if (client->writer_started) thread_interrupt(client->writer_thread); + if (client->reader_started) thread_interrupt(client->reader_thread); + if (client->stderr_started) thread_interrupt(client->stderr_thread); + ss_process_close_stdin(client->process); + if (!ss_process_wait(client->process, deadline)) ss_process_terminate(client->process); + if (client->writer_started) thread_interrupt(client->writer_thread); + if (client->reader_started) thread_interrupt(client->reader_thread); + if (client->stderr_started) thread_interrupt(client->stderr_thread); + if (client->writer_started) { + thread_join(client->writer_thread); + client->writer_started = false; + } + if (client->deadline_started) { + thread_join(client->deadline_thread); + client->deadline_started = false; + } + mutex_lock(&client->write_mutex); + outbound = client->write_head; + client->write_head = NULL; + client->write_tail = NULL; + client->write_count = 0; + mutex_unlock(&client->write_mutex); + while (outbound != NULL) { + ss_outbound *next = outbound->next; + outbound_free(outbound); + outbound = next; + } + if (client->reader_started) { + thread_join(client->reader_thread); + client->reader_started = false; + } + if (client->stderr_started) { + thread_join(client->stderr_thread); + client->stderr_started = false; + } + ss_process_free(client->process); + client->process = NULL; +} + +static bool options_valid(const secretspec_resolver_options *options) { + size_t minimum_size = offsetof(secretspec_resolver_options, max_stderr_bytes); + size_t index; + if (options == NULL || options->struct_size < minimum_size || + options->struct_size > sizeof(*options) || + options->abi_version != SECRETSPEC_RESOLVER_ABI_VERSION || + (options->flags & ~SS_KNOWN_FLAGS) != 0 || options->reserved != 0 || + !valid_slice(options->executable, false) || + !valid_slice(options->initialize_params_json, false) || + (options->argument_count != 0 && options->arguments == NULL) || + (options->environment_count != 0 && options->environment == NULL) || + options->argument_count > 4096 || options->environment_count > 4096) return false; + for (index = 0; index < options->argument_count; index++) { + if (!valid_slice(options->arguments[index], true)) return false; + } + for (index = 0; index < options->environment_count; index++) { + if (!valid_slice(options->environment[index], false)) return false; + } + if (options->struct_size >= sizeof(*options) && options->max_stderr_bytes > SS_ABSOLUTE_MAX_FRAME) return false; + return true; +} + +secretspec_resolver_status secretspec_resolver_client_open( + const secretspec_resolver_options *options, + uint64_t deadline_unix_ms, + secretspec_resolver_client **client_out, + secretspec_resolver_buffer *server_info, + secretspec_resolver_buffer *error) { + ss_launch launch; + secretspec_resolver_client *client = NULL; + yyjson_doc *initialize_document = NULL; + yyjson_val *initialize_root; + secretspec_resolver_call *initialize_call = NULL; + secretspec_resolver_buffer initialize_result = {NULL, 0}; + secretspec_resolver_status status; + + if (client_out != NULL) *client_out = NULL; + ss_buffer_reset(server_info); + ss_buffer_reset(error); + if (client_out == NULL || server_info == NULL || error == NULL || !options_valid(options)) { + ss_set_error(error, "invalid_argument", "invalid client options"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + /* A deadline that has already passed is a timing outcome, not a malformed + * argument, and the Rust client reports it as one. Reporting it here as + * invalid_argument would put a cliff at the current instant: the same call + * a millisecond earlier returns a different kind. */ + if (deadline_unix_ms <= ss_now_unix_ms()) { + ss_set_error(error, "deadline_exceeded", "request deadline already elapsed"); + return SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; + } + if (!ss_json_validate(options->initialize_params_json.data, + options->initialize_params_json.size, + &initialize_document)) { + ss_set_error(error, "invalid_argument", "invalid initialization JSON"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + initialize_root = yyjson_doc_get_root(initialize_document); + if (!initialize_offer_valid(initialize_root)) { + yyjson_doc_free(initialize_document); + ss_set_error(error, "invalid_argument", "invalid initialization params"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + /* The library owns what it advertises, so the caller cannot claim a + * capability this build could not answer. `initialize_offer_valid` already + * rejects a caller-supplied `client_methods`, which is what makes + * adding it here unambiguous. */ + if ((options->flags & SECRETSPEC_RESOLVER_ANSWER_PROMPTS) != 0) { + yyjson_doc *advertised = NULL; + if (!offer_with_prompt_capability(initialize_root, &advertised)) { + yyjson_doc_free(initialize_document); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + yyjson_doc_free(initialize_document); + initialize_document = advertised; + initialize_root = yyjson_doc_get_root(initialize_document); + } + status = launch_from_options(options, &launch); + if (status != SECRETSPEC_RESOLVER_OK) { + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, status == SECRETSPEC_RESOLVER_INVALID_ARGUMENT ? "invalid_argument" : "unavailable", + status == SECRETSPEC_RESOLVER_INVALID_ARGUMENT ? "invalid launch options" : "allocation failed"); + return status; + } + client = (secretspec_resolver_client *)calloc(1, sizeof(*client)); + if (client == NULL) { + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + if (!mutex_init(&client->mutex)) { + free(client); + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + if (!mutex_init(&client->write_mutex)) { + mutex_destroy(&client->mutex); + free(client); + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + if (!condition_init(&client->state_changed)) { + mutex_destroy(&client->write_mutex); + mutex_destroy(&client->mutex); + free(client); + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + if (!condition_init(&client->write_ready)) { + condition_destroy(&client->state_changed); + mutex_destroy(&client->write_mutex); + mutex_destroy(&client->mutex); + free(client); + yyjson_doc_free(initialize_document); + launch_free(&launch); + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + atomic_init(&client->references, 1); + client->initializing = true; + client->max_frame_bytes = SS_ABSOLUTE_MAX_FRAME; + client->max_in_flight = 1; + client->next_id = 1; + client->answer_prompts = (options->flags & SECRETSPEC_RESOLVER_ANSWER_PROMPTS) != 0; + client->max_stderr_bytes = options->struct_size >= sizeof(*options) ? options->max_stderr_bytes : 65536; + status = ss_process_spawn(&launch, &client->process); + launch_free(&launch); + if (status != SECRETSPEC_RESOLVER_OK) { + yyjson_doc_free(initialize_document); + client_destroy(client); + ss_set_error(error, "unavailable", "child launch failed"); + return status; + } + if (!thread_start(&client->writer_thread, writer_main, client)) { + yyjson_doc_free(initialize_document); + cleanup_process(client, ss_now_unix_ms()); + client_destroy(client); + ss_set_error(error, "unavailable", "writer worker failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + client->writer_started = true; + if (!thread_start(&client->reader_thread, reader_main, client)) { + yyjson_doc_free(initialize_document); + cleanup_process(client, ss_now_unix_ms()); + client_destroy(client); + ss_set_error(error, "unavailable", "reader worker failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + client->reader_started = true; + if (!thread_start(&client->stderr_thread, stderr_main, client)) { + yyjson_doc_free(initialize_document); + cleanup_process(client, ss_now_unix_ms()); + client_destroy(client); + ss_set_error(error, "unavailable", "stderr worker failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + client->stderr_started = true; + if (!thread_start(&client->deadline_thread, deadline_main, client)) { + yyjson_doc_free(initialize_document); + cleanup_process(client, ss_now_unix_ms()); + client_destroy(client); + ss_set_error(error, "unavailable", "deadline worker failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + client->deadline_started = true; + + status = start_request(client, "rpc.initialize", strlen("rpc.initialize"), + initialize_root, deadline_unix_ms, false, &initialize_call); + if (status == SECRETSPEC_RESOLVER_OK) { + status = wait_call(initialize_call, &initialize_result, error); + } + if (initialize_call != NULL) secretspec_resolver_call_free(initialize_call); + if (status == SECRETSPEC_RESOLVER_OK && + !validate_initialize_result(client, initialize_root, + initialize_result.data, initialize_result.size)) { + status = SECRETSPEC_RESOLVER_PROTOCOL; + ss_set_error(error, "protocol", "invalid initialization response"); + } + yyjson_doc_free(initialize_document); + if (status != SECRETSPEC_RESOLVER_OK) { + secretspec_resolver_buffer_free(initialize_result); + mutex_lock(&client->mutex); + client->initializing = false; + condition_broadcast(&client->state_changed); + mutex_unlock(&client->mutex); + cleanup_process(client, deadline_unix_ms); + client_destroy(client); + return status; + } + + *server_info = initialize_result; + mutex_lock(&client->mutex); + client->initializing = false; + client->ready = true; + condition_broadcast(&client->state_changed); + mutex_unlock(&client->mutex); + *client_out = client; + return SECRETSPEC_RESOLVER_OK; +} + +secretspec_resolver_status secretspec_resolver_call_start( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_call **call, + secretspec_resolver_buffer *error) { + yyjson_doc *document = NULL; + yyjson_val *root; + secretspec_resolver_status status; + ss_buffer_reset(error); + if (call != NULL) *call = NULL; + if (client == NULL || call == NULL || error == NULL || method == NULL || method_size == 0 || + method_size > 256 || params_json == NULL || params_size == 0 || + memchr(method, 0, method_size) != NULL || !valid_utf8(method, method_size)) { + ss_set_error(error, "invalid_argument", "invalid call arguments"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + /* See the note in the open path: an elapsed deadline is a timing outcome. */ + if (deadline_unix_ms <= ss_now_unix_ms()) { + ss_set_error(error, "deadline_exceeded", "request deadline already elapsed"); + return SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; + } + if (!ss_json_validate(params_json, params_size, &document)) { + ss_set_error(error, "invalid_argument", "invalid call arguments"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + root = yyjson_doc_get_root(document); + mutex_lock(&client->mutex); + if (!method_advertised(client, (const char *)method, method_size)) { + mutex_unlock(&client->mutex); + yyjson_doc_free(document); + ss_set_error(error, "protocol", "method was not advertised"); + return SECRETSPEC_RESOLVER_PROTOCOL; + } + mutex_unlock(&client->mutex); + status = start_request(client, (const char *)method, method_size, root, + deadline_unix_ms, true, call); + yyjson_doc_free(document); + if (status != SECRETSPEC_RESOLVER_OK) { + if (*call != NULL) { + secretspec_resolver_call_free(*call); + *call = NULL; + } + ss_set_error(error, status == SECRETSPEC_RESOLVER_UNAVAILABLE ? "unavailable" : "io", + status == SECRETSPEC_RESOLVER_UNAVAILABLE ? "session capacity unavailable" : "request write failed"); + } + return status; +} + +secretspec_resolver_status secretspec_resolver_client_call( + secretspec_resolver_client *client, + const unsigned char *method, + size_t method_size, + const unsigned char *params_json, + size_t params_size, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error) { + secretspec_resolver_call *call = NULL; + secretspec_resolver_status status; + if (result == NULL || error == NULL) { + if (error != NULL) ss_set_error(error, "invalid_argument", "invalid call outputs"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + ss_buffer_reset(result); + ss_buffer_reset(error); + /* This form returns the result and drops the handle, so it has no way to + * resume a call that stopped for a prompt. A prompt-answering session must + * drive calls with call_start and call_wait, which can. */ + if (client != NULL && client->answer_prompts) { + ss_set_error(error, "invalid_argument", + "a prompt-answering session must use call_start and call_wait"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + status = secretspec_resolver_call_start(client, method, method_size, params_json, params_size, + deadline_unix_ms, &call, error); + if (status == SECRETSPEC_RESOLVER_OK) status = secretspec_resolver_call_wait(call, result, error); + secretspec_resolver_call_free(call); + return status; +} + +secretspec_resolver_status secretspec_resolver_call_wait( + secretspec_resolver_call *call, + secretspec_resolver_buffer *result, + secretspec_resolver_buffer *error) { + ss_buffer_reset(result); + ss_buffer_reset(error); + if (call == NULL || result == NULL || error == NULL) { + ss_set_error(error, "invalid_argument", "invalid call handle"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + return wait_call(call, result, error); +} + +void secretspec_resolver_call_cancel(secretspec_resolver_call *call) { + bool send = false; + if (call == NULL) return; + mutex_lock(&call->client->mutex); + if (call->request->running && !call->request->cancel_sent) { + call->request->cancel_sent = true; + prompts_cancel_parent(call->client, call->request->id); + send = true; + } + mutex_unlock(&call->client->mutex); + if (send) (void)send_cancel(call->client, call->request->id); +} + +void secretspec_resolver_call_free(secretspec_resolver_call *call) { + secretspec_resolver_client *client; + if (call == NULL) return; + client = call->client; + secretspec_resolver_call_cancel(call); + request_release(call->request); + ss_secure_clear(call, sizeof(*call)); + free(call); + client_release(client); +} + +secretspec_resolver_status secretspec_resolver_prompt_take( + secretspec_resolver_client *client, + secretspec_resolver_prompt **prompt, + secretspec_resolver_buffer *error) { + ss_prompt *taken; + if (client == NULL || prompt == NULL || error == NULL) { + ss_set_error(error, "invalid_argument", "invalid prompt arguments"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + *prompt = NULL; + ss_buffer_reset(error); + mutex_lock(&client->mutex); + if (client->closed) { + mutex_unlock(&client->mutex); + ss_set_error(error, "unavailable", "session closed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + prompts_expire(client, ss_now_unix_ms()); + taken = client->prompts; + if (taken != NULL) { + client->prompts = taken->next; + client->prompt_count--; + taken->next = NULL; + /* The prompt holds the session open while the caller decides: the + * answer still has to be written on this transport. */ + (void)atomic_fetch_add(&client->references, 1); + } + mutex_unlock(&client->mutex); + *prompt = (secretspec_resolver_prompt *)taken; + return SECRETSPEC_RESOLVER_OK; +} + +secretspec_resolver_slice secretspec_resolver_prompt_params(const secretspec_resolver_prompt *prompt) { + secretspec_resolver_slice slice = {NULL, 0}; + const ss_prompt *inner = (const ss_prompt *)prompt; + if (inner == NULL) return slice; + slice.data = inner->params.data; + slice.size = inner->params.size; + return slice; +} + +/* Write one terminal response for a prompt. `value` is the answer, or NULL to + * decline with interaction_required. Marks the prompt answered first so a + * second call cannot put two responses on the wire for one request. */ +static secretspec_resolver_status answer_prompt( + ss_prompt *prompt, + const unsigned char *value, + size_t value_size, + secretspec_resolver_buffer *error) { + secretspec_resolver_client *client; + yyjson_mut_doc *document; + yyjson_mut_val *root; + yyjson_mut_val *body; + char *json = NULL; + size_t size = 0; + bool written = false; + if (prompt == NULL || error == NULL) { + ss_set_error(error, "invalid_argument", "invalid prompt arguments"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + ss_buffer_reset(error); + client = prompt->client; + mutex_lock(&client->mutex); + if (prompt->answered) { + mutex_unlock(&client->mutex); + ss_set_error(error, "invalid_argument", "prompt was already answered"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + prompt->answered = true; + if (client->closed) { + mutex_unlock(&client->mutex); + ss_set_error(error, "unavailable", "session closed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + { + ss_request *parent = find_request(client, prompt->parent_request_id); + if (parent == NULL || !parent->running || parent->cancel_sent || + prompt->deadline_unix_ms > parent->deadline_unix_ms) { + mutex_unlock(&client->mutex); + ss_set_error(error, "cancelled", "prompt parent is no longer active"); + return SECRETSPEC_RESOLVER_CANCELLED; + } + } + if (prompt->deadline_unix_ms <= ss_now_unix_ms()) { + mutex_unlock(&client->mutex); + ss_set_error(error, "deadline_exceeded", "prompt deadline exceeded"); + return SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; + } + mutex_unlock(&client->mutex); + + document = yyjson_mut_doc_new(NULL); + if (document == NULL) { + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + root = yyjson_mut_obj(document); + body = yyjson_mut_obj(document); + if (root != NULL && body != NULL && + yyjson_mut_obj_add_str(document, root, "jsonrpc", "2.0") && + yyjson_mut_obj_add_uint(document, root, "id", prompt->id)) { + if (value != NULL) { + written = yyjson_mut_obj_add_strncpy(document, body, "value", + (const char *)value, value_size) && + yyjson_mut_obj_add_val(document, root, "result", body); + } else { + yyjson_mut_val *data = yyjson_mut_obj(document); + written = data != NULL && + yyjson_mut_obj_add_str(document, body, "kind", "interaction_required") && + yyjson_mut_obj_add_bool(document, body, "retryable", false) && + yyjson_mut_obj_add_int(document, data, "code", -32006) && + yyjson_mut_obj_add_str(document, data, "message", "interaction required") && + yyjson_mut_obj_add_val(document, data, "data", body) && + yyjson_mut_obj_add_val(document, root, "error", data); + } + } + if (written) { + yyjson_mut_doc_set_root(document, root); + json = yyjson_mut_write(document, YYJSON_WRITE_NOFLAG, &size); + } + yyjson_mut_doc_free(document); + if (json == NULL) { + ss_set_error(error, "unavailable", "allocation failed"); + return SECRETSPEC_RESOLVER_UNAVAILABLE; + } + /* Recheck after serialization and queue under the same lock. The reader + * cannot make the parent terminal between this check and the enqueue. */ + mutex_lock(&client->mutex); + { + ss_request *parent = find_request(client, prompt->parent_request_id); + if (parent == NULL || !parent->running || parent->cancel_sent || + prompt->deadline_unix_ms > parent->deadline_unix_ms) { + mutex_unlock(&client->mutex); + ss_secure_clear(json, size); + free(json); + ss_set_error(error, "cancelled", "prompt parent is no longer active"); + return SECRETSPEC_RESOLVER_CANCELLED; + } + } + written = write_payload_locked(client, (const unsigned char *)json, size); + mutex_unlock(&client->mutex); + /* The answer is a secret, so this copy goes before the pointer does. */ + ss_secure_clear(json, size); + free(json); + if (!written) { + ss_set_error(error, "io", "failed to write the prompt answer"); + return SECRETSPEC_RESOLVER_IO; + } + return SECRETSPEC_RESOLVER_OK; +} + +secretspec_resolver_status secretspec_resolver_prompt_answer( + secretspec_resolver_prompt *prompt, + const unsigned char *value, + size_t value_size, + secretspec_resolver_buffer *error) { + /* An empty answer means different things to different stores, exactly as it + * does for resolver.set, so it never travels. A person who wants to refuse + * declines instead. */ + if (value == NULL || value_size == 0 || value_size > SS_ABSOLUTE_MAX_FRAME || + !valid_utf8(value, value_size)) { + ss_set_error(error, "invalid_argument", "prompt answer must be nonempty valid UTF-8"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + return answer_prompt((ss_prompt *)prompt, value, value_size, error); +} + +secretspec_resolver_status secretspec_resolver_prompt_decline( + secretspec_resolver_prompt *prompt, + secretspec_resolver_buffer *error) { + return answer_prompt((ss_prompt *)prompt, NULL, 0, error); +} + +void secretspec_resolver_prompt_free(secretspec_resolver_prompt *prompt) { + ss_prompt *inner = (ss_prompt *)prompt; + secretspec_resolver_client *client; + if (inner == NULL) return; + client = inner->client; + /* Freeing without answering would leave the endpoint waiting out its + * deadline, so decline on the caller's behalf rather than going silent. */ + if (!inner->answered) { + secretspec_resolver_buffer ignored = {NULL, 0}; + (void)answer_prompt(inner, NULL, 0, &ignored); + secretspec_resolver_buffer_free(ignored); + } + secretspec_resolver_buffer_free(inner->params); + ss_secure_clear(inner, sizeof(*inner)); + free(inner); + client_release(client); +} + +secretspec_resolver_status secretspec_resolver_client_close( + secretspec_resolver_client *client, + uint64_t deadline_unix_ms, + secretspec_resolver_buffer *error) { + static const unsigned char params[] = "{}"; + yyjson_doc *document = NULL; + secretspec_resolver_call *shutdown_call = NULL; + secretspec_resolver_buffer result = {NULL, 0}; + secretspec_resolver_status status = SECRETSPEC_RESOLVER_OK; + ss_buffer_reset(error); + if (client == NULL || error == NULL || deadline_unix_ms <= ss_now_unix_ms()) { + ss_set_error(error, "invalid_argument", "invalid close arguments"); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + mutex_lock(&client->mutex); + if (client->closing) { + mutex_unlock(&client->mutex); + return SECRETSPEC_RESOLVER_INVALID_ARGUMENT; + } + if (client->closed) { + mutex_unlock(&client->mutex); + cleanup_process(client, deadline_unix_ms); + return SECRETSPEC_RESOLVER_OK; + } + client->closing = true; + mutex_unlock(&client->mutex); + if (!ss_json_validate(params, sizeof(params) - 1, &document)) { + status = SECRETSPEC_RESOLVER_PROTOCOL; + } else { + status = start_request(client, "rpc.shutdown", strlen("rpc.shutdown"), + yyjson_doc_get_root(document), deadline_unix_ms, false, + &shutdown_call); + if (status == SECRETSPEC_RESOLVER_OK) status = wait_call(shutdown_call, &result, error); + } + yyjson_doc_free(document); + if (shutdown_call != NULL) secretspec_resolver_call_free(shutdown_call); + if (status == SECRETSPEC_RESOLVER_OK) { + yyjson_doc *result_document = NULL; + if (!ss_json_validate(result.data, result.size, &result_document) || + !yyjson_is_obj(yyjson_doc_get_root(result_document)) || + yyjson_obj_size(yyjson_doc_get_root(result_document)) != 0) { + status = SECRETSPEC_RESOLVER_PROTOCOL; + ss_set_error(error, "protocol", "invalid shutdown response"); + } + yyjson_doc_free(result_document); + } + secretspec_resolver_buffer_free(result); + cleanup_process(client, deadline_unix_ms); + return status; +} + +void secretspec_resolver_client_free(secretspec_resolver_client *client) { + secretspec_resolver_buffer error = {NULL, 0}; + bool needs_cleanup; + bool orderly_close; + if (client == NULL) return; + mutex_lock(&client->mutex); + if (client->user_released) { + mutex_unlock(&client->mutex); + return; + } + client->user_released = true; + needs_cleanup = client->process != NULL; + orderly_close = client->ready && !client->closing && !client->closed; + mutex_unlock(&client->mutex); + if (needs_cleanup) { + if (orderly_close) { + (void)secretspec_resolver_client_close(client, ss_now_unix_ms() + UINT64_C(5000), &error); + secretspec_resolver_buffer_free(error); + } else { + cleanup_process(client, ss_now_unix_ms() + UINT64_C(5000)); + } + } + client_release(client); +} diff --git a/libsecretspec-resolver/tests/backpressure.c b/libsecretspec-resolver/tests/backpressure.c new file mode 100644 index 000000000..1012a0e9a --- /dev/null +++ b/libsecretspec-resolver/tests/backpressure.c @@ -0,0 +1,108 @@ +#include "secretspec_resolver.h" + +#include +#include +#include +#include +#include + +static uint64_t now_ms(void) { + struct timespec time; + if (timespec_get(&time, TIME_UTC) != TIME_UTC) return 0; + return (uint64_t)time.tv_sec * UINT64_C(1000) + + (uint64_t)time.tv_nsec / UINT64_C(1000000); +} + +static secretspec_resolver_slice slice(const char *text) { + secretspec_resolver_slice value; + value.data = (const unsigned char *)text; + value.size = strlen(text); + return value; +} + +int main(int argc, char **argv) { + static const char initialize[] = + "{\"protocol\":\"secretspec.resolver\",\"versions\":[1]," + "\"client\":{\"name\":\"c-test\",\"version\":\"1\"}," + "\"limits\":{\"max_frame_bytes\":32768,\"max_in_flight\":4}," + "\"application\":{}}"; + const char *peer_argument = "--stall-after-init"; + secretspec_resolver_slice arguments[1]; + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *calls[4] = {NULL, NULL, NULL, NULL}; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + char *params = NULL; + uint64_t call_deadline; + uint64_t started; + size_t prefix; + size_t index; + + if (argc != 2) return EXIT_FAILURE; + arguments[0] = slice(peer_argument); + memset(&options, 0, sizeof(options)); + options.struct_size = sizeof(options); + options.abi_version = SECRETSPEC_RESOLVER_ABI_VERSION; + options.executable = slice(argv[1]); + options.arguments = arguments; + options.argument_count = 1; + options.initialize_params_json.data = (const unsigned char *)initialize; + options.initialize_params_json.size = sizeof(initialize) - 1; + options.max_stderr_bytes = 4096; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + secretspec_resolver_buffer_free(server); + server.data = NULL; + server.size = 0; + + params = (char *)malloc(30001); + if (params == NULL) goto failed; + call_deadline = now_ms() + UINT64_C(2000); + prefix = (size_t)snprintf(params, 30001, "{\"padding\":\""); + if (prefix >= 29998) goto failed; + memset(params + prefix, 'a', 29998 - prefix); + params[29998] = '\"'; + params[29999] = '}'; + params[30000] = '\0'; + + started = now_ms(); + for (index = 0; index < 4; index++) { + status = secretspec_resolver_call_start( + client, + (const unsigned char *)"resolver.get", + strlen("resolver.get"), + (const unsigned char *)params, + strlen(params), + call_deadline, + &calls[index], + &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + } + if (now_ms() - started >= UINT64_C(1000)) goto failed; + + (void)secretspec_resolver_client_close(client, now_ms() + UINT64_C(250), &error); + secretspec_resolver_buffer_free(error); + error.data = NULL; + error.size = 0; + for (index = 0; index < 4; index++) { + secretspec_resolver_call_free(calls[index]); + calls[index] = NULL; + } + secretspec_resolver_client_free(client); + free(params); + return EXIT_SUCCESS; + +failed: + if (error.data != NULL) fwrite(error.data, 1, error.size, stderr); + secretspec_resolver_buffer_free(error); + secretspec_resolver_buffer_free(server); + for (index = 0; index < 4; index++) { + if (calls[index] != NULL) secretspec_resolver_call_free(calls[index]); + } + if (client != NULL) secretspec_resolver_client_free(client); + free(params); + return EXIT_FAILURE; +} diff --git a/libsecretspec-resolver/tests/fake_peer.c b/libsecretspec-resolver/tests/fake_peer.c new file mode 100644 index 000000000..0a5b23d44 --- /dev/null +++ b/libsecretspec-resolver/tests/fake_peer.c @@ -0,0 +1,358 @@ +#ifndef _WIN32 +#define _POSIX_C_SOURCE 200809L +#endif + +#include "yyjson.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#else +#include +#include +#endif + +typedef enum { + MODE_NORMAL, + MODE_STALL_AFTER_INIT, + MODE_BAD_SHUTDOWN, + MODE_IGNORE_CALLS, + MODE_DESCENDANT_HOLDS_PIPES, + MODE_HOLD_PIPES, + MODE_BANNER_ON_STDOUT, + MODE_FUTURE_ERROR_KIND, + MODE_PROMPT, + MODE_EXPIRED_PROMPT, + MODE_PARENT_TERMINAL_PROMPT, + MODE_LATE_DEADLINE_PROMPT, + MODE_UNKNOWN_NOTIFICATION, + MODE_INVALID_NOTIFICATION, + MODE_CHECK_ENVIRONMENT +} peer_mode; + +static uint64_t now_ms(void) { + struct timespec time; + if (timespec_get(&time, TIME_UTC) != TIME_UTC) return 0; + return (uint64_t)time.tv_sec * UINT64_C(1000) + + (uint64_t)time.tv_nsec / UINT64_C(1000000); +} + +static void pause_for_backpressure(void) { +#ifdef _WIN32 + Sleep(10000); +#else + struct timespec delay = {10, 0}; + (void)nanosleep(&delay, NULL); +#endif +} + +static void pause_milliseconds(uint64_t milliseconds) { +#ifdef _WIN32 + Sleep(milliseconds > MAXDWORD ? MAXDWORD : (DWORD)milliseconds); +#else + struct timespec delay; + delay.tv_sec = (time_t)(milliseconds / UINT64_C(1000)); + delay.tv_nsec = (long)((milliseconds % UINT64_C(1000)) * UINT64_C(1000000)); + (void)nanosleep(&delay, NULL); +#endif +} + +#ifdef _WIN32 +static size_t environment_key_size(const wchar_t *entry) { + const wchar_t *equals = wcschr(entry + (entry[0] == L'=' ? 1 : 0), L'='); + return equals == NULL ? wcslen(entry) : (size_t)(equals - entry); +} + +static int environment_names_are_sorted(void) { + wchar_t *block = GetEnvironmentStringsW(); + wchar_t *entry; + wchar_t *previous = NULL; + int sorted = 1; + if (block == NULL) return 0; + for (entry = block; *entry != L'\0'; entry += wcslen(entry) + 1) { + if (previous != NULL) { + size_t previous_size = environment_key_size(previous); + size_t entry_size = environment_key_size(entry); + size_t common = previous_size < entry_size ? previous_size : entry_size; + int compared = _wcsnicmp(previous, entry, common); + if (compared > 0 || (compared == 0 && previous_size > entry_size)) { + sorted = 0; + break; + } + } + previous = entry; + } + FreeEnvironmentStringsW(block); + return sorted; +} +#else +static int environment_names_are_sorted(void) { + return 1; +} +#endif + +static int expected_environment_is_present(void) { + const char *first = getenv("secretspec_a_first"); + const char *middle = getenv("SecretSpec_M_Middle"); + const char *last = getenv("SECRETSPEC_Z_LAST"); + return first != NULL && strcmp(first, "first") == 0 && + middle != NULL && strcmp(middle, "middle") == 0 && + last != NULL && strcmp(last, "last") == 0 && + environment_names_are_sorted(); +} + +static int read_frame(unsigned char **payload, size_t *size) { + int byte; + *payload = (unsigned char *)malloc(1048576); + if (*payload == NULL) return 0; + *size = 0; + while ((byte = fgetc(stdin)) != EOF) { + if (byte == '\n') return *size != 0; + if (byte == '\r' || *size == 1048576) { free(*payload); return 0; } + (*payload)[(*size)++] = (unsigned char)byte; + } + free(*payload); + return 0; +} + +static int write_frame(const char *payload) { + size_t size = strlen(payload); + return fwrite(payload, 1, size, stdout) == size && fputc('\n', stdout) != EOF && fflush(stdout) == 0; +} + +static int start_pipe_holder(const char *executable) { +#ifdef _WIN32 + STARTUPINFOA startup; + PROCESS_INFORMATION process; + char command[4096]; + int length; + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + length = snprintf(command, sizeof(command), "\"%s\" --hold-pipes", executable); + if (length <= 0 || (size_t)length >= sizeof(command) || + !CreateProcessA(NULL, command, NULL, NULL, TRUE, CREATE_NO_WINDOW, + NULL, NULL, &startup, &process)) return 0; + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return 1; +#else + pid_t child = fork(); + (void)executable; + if (child < 0) return 0; + if (child == 0) { + struct timespec delay = {5, 0}; + (void)nanosleep(&delay, NULL); + _exit(EXIT_SUCCESS); + } + return 1; +#endif +} + +static peer_mode parse_mode(int argc, char **argv) { + if (argc != 2) return MODE_NORMAL; + if (strcmp(argv[1], "--stall-after-init") == 0) return MODE_STALL_AFTER_INIT; + if (strcmp(argv[1], "--bad-shutdown") == 0) return MODE_BAD_SHUTDOWN; + if (strcmp(argv[1], "--ignore-calls") == 0) return MODE_IGNORE_CALLS; + if (strcmp(argv[1], "--descendant-holds-pipes") == 0) return MODE_DESCENDANT_HOLDS_PIPES; + if (strcmp(argv[1], "--hold-pipes") == 0) return MODE_HOLD_PIPES; + if (strcmp(argv[1], "--banner-on-stdout") == 0) return MODE_BANNER_ON_STDOUT; + if (strcmp(argv[1], "--future-error-kind") == 0) return MODE_FUTURE_ERROR_KIND; + if (strcmp(argv[1], "--prompt") == 0) return MODE_PROMPT; + if (strcmp(argv[1], "--expired-prompt") == 0) return MODE_EXPIRED_PROMPT; + if (strcmp(argv[1], "--parent-terminal-prompt") == 0) return MODE_PARENT_TERMINAL_PROMPT; + if (strcmp(argv[1], "--late-deadline-prompt") == 0) return MODE_LATE_DEADLINE_PROMPT; + if (strcmp(argv[1], "--unknown-notification") == 0) return MODE_UNKNOWN_NOTIFICATION; + if (strcmp(argv[1], "--invalid-notification") == 0) return MODE_INVALID_NOTIFICATION; + if (strcmp(argv[1], "--check-environment") == 0) return MODE_CHECK_ENVIRONMENT; + return MODE_NORMAL; +} + +int main(int argc, char **argv) { + peer_mode mode = parse_mode(argc, argv); + int expired_prompt_sent = 0; +#ifdef _WIN32 + /* The wire format requires LF; Windows text mode expands it to CRLF. */ + if (_setmode(_fileno(stdin), _O_BINARY) == -1 || + _setmode(_fileno(stdout), _O_BINARY) == -1) { + return EXIT_FAILURE; + } +#endif + if (mode == MODE_HOLD_PIPES) { + pause_for_backpressure(); + return EXIT_SUCCESS; + } + if (mode == MODE_BANNER_ON_STDOUT) { + /* The endpoint bug this diagnostic exists for: a banner on the stream + * reserved for frames, before a single frame is written. */ + (void)fputs("secretspec-provider-example starting\n", stdout); + (void)fflush(stdout); + pause_for_backpressure(); + return EXIT_SUCCESS; + } + if (mode == MODE_CHECK_ENVIRONMENT && !expected_environment_is_present()) { + return EXIT_FAILURE; + } + for (;;) { + unsigned char *payload = NULL; + size_t size = 0; + yyjson_doc *document; + yyjson_val *root; + yyjson_val *method; + yyjson_val *id; + yyjson_val *deadline; + char response[2048]; + int length; + if (!read_frame(&payload, &size)) return EXIT_FAILURE; + document = yyjson_read((char *)payload, size, 0); + free(payload); + if (document == NULL) return EXIT_FAILURE; + root = yyjson_doc_get_root(document); + method = yyjson_obj_get(root, "method"); + id = yyjson_obj_get(root, "id"); + deadline = yyjson_obj_get(yyjson_obj_get(root, "_meta"), "deadline_unix_ms"); + if (id != NULL && !yyjson_is_uint(deadline)) { + yyjson_doc_free(document); + return EXIT_FAILURE; + } + if (yyjson_equals_str(method, "rpc.initialize")) { + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{" + "\"protocol\":\"secretspec.resolver\",\"version\":1," + "\"server\":{\"name\":\"fake-peer\",\"version\":\"1\"}," + "\"methods\":[\"resolver.get\",\"resolver.release\"],\"capabilities\":{}," + "\"limits\":{\"max_frame_bytes\":32768,\"max_in_flight\":4}," + "\"application\":{}}}", + (unsigned long long)yyjson_get_uint(id)); + } else if (yyjson_equals_str(method, "rpc.shutdown")) { + length = snprintf(response, sizeof(response), + mode == MODE_BAD_SHUTDOWN + ? "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"unexpected\":true}}" + : "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{}}", + (unsigned long long)yyjson_get_uint(id)); + yyjson_doc_free(document); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + if (mode == MODE_DESCENDANT_HOLDS_PIPES && !start_pipe_holder(argv[0])) return EXIT_FAILURE; + return EXIT_SUCCESS; + } else if (id == NULL) { + yyjson_doc_free(document); + continue; + } else if (mode == MODE_IGNORE_CALLS) { + yyjson_doc_free(document); + continue; + } else if (mode == MODE_FUTURE_ERROR_KIND) { + /* A peer speaking a later revision: an error code and kind this + * client has never heard of. It must survive it. */ + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"error\":{\"code\":-32011," + "\"message\":\"dynamic session required\"," + "\"data\":{\"kind\":\"dynamic_session_required\",\"retryable\":false}}}", + (unsigned long long)yyjson_get_uint(id)); + } else if (mode == MODE_PROMPT) { + /* Ask the client for a value mid-call, then answer the call with + * whatever came back. The prompt uses this side's own request ID + * space, which deliberately overlaps the client's. */ + unsigned char *answer = NULL; + size_t answer_size = 0; + yyjson_doc *reply; + yyjson_val *value; + uint64_t call_id = yyjson_get_uint(id); + uint64_t call_deadline = yyjson_get_uint(deadline); + yyjson_doc_free(document); + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"client.prompt\"," + "\"_meta\":{\"deadline_unix_ms\":%llu,\"parent_request_id\":%llu},\"params\":{\"name\":\"DEPLOY_PASSWORD\"," + "\"profile\":\"default\",\"target_provider\":\"dotenv:values.env\"}}", + (unsigned long long)call_deadline, (unsigned long long)call_id); + if (length <= 0 || (size_t)length >= sizeof(response) || + !write_frame(response) || !read_frame(&answer, &answer_size)) return EXIT_FAILURE; + reply = yyjson_read((char *)answer, answer_size, 0); + free(answer); + if (reply == NULL) return EXIT_FAILURE; + value = yyjson_obj_get(yyjson_obj_get(yyjson_doc_get_root(reply), "result"), "value"); + length = yyjson_is_str(value) + ? snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"answered\":\"%s\"}}", + (unsigned long long)call_id, yyjson_get_str(value)) + : snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"declined\":true}}", + (unsigned long long)call_id); + yyjson_doc_free(reply); + if (length <= 0 || (size_t)length >= sizeof(response) || + !write_frame(response)) return EXIT_FAILURE; + continue; + } else if (mode == MODE_EXPIRED_PROMPT && !expired_prompt_sent) { + /* Leave the first call unanswered after asking a short-lived + * question. Later calls still receive normal responses, which + * exposes a stale prompt that was not removed at its deadline. */ + expired_prompt_sent = 1; + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"client.prompt\"," + "\"_meta\":{\"deadline_unix_ms\":%llu,\"parent_request_id\":%llu},\"params\":{\"name\":\"STALE_SECRET\"," + "\"profile\":\"default\",\"target_provider\":null}}", + (unsigned long long)(now_ms() + UINT64_C(100)), (unsigned long long)yyjson_get_uint(id)); + yyjson_doc_free(document); + if (length <= 0 || (size_t)length >= sizeof(response) || + !write_frame(response)) return EXIT_FAILURE; + continue; + } else if (mode == MODE_PARENT_TERMINAL_PROMPT && !expired_prompt_sent) { + uint64_t call_id = yyjson_get_uint(id); + expired_prompt_sent = 1; + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"client.prompt\"," + "\"_meta\":{\"deadline_unix_ms\":%llu,\"parent_request_id\":%llu}," + "\"params\":{\"name\":\"LATE_SECRET\",\"profile\":\"default\",\"target_provider\":null}}", + (unsigned long long)yyjson_get_uint(deadline), (unsigned long long)call_id); + yyjson_doc_free(document); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + pause_milliseconds(UINT64_C(100)); + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"terminal\":true}}", + (unsigned long long)call_id); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + continue; + } else if (mode == MODE_LATE_DEADLINE_PROMPT && !expired_prompt_sent) { + expired_prompt_sent = 1; + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"client.prompt\"," + "\"_meta\":{\"deadline_unix_ms\":%llu,\"parent_request_id\":%llu},\"params\":{}}", + (unsigned long long)(yyjson_get_uint(deadline) + UINT64_C(1)), + (unsigned long long)yyjson_get_uint(id)); + yyjson_doc_free(document); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + continue; + } else if (mode == MODE_UNKNOWN_NOTIFICATION) { + uint64_t call_id = yyjson_get_uint(id); + yyjson_doc_free(document); + if (!write_frame("{\"jsonrpc\":\"2.0\",\"method\":\"future.notice\",\"params\":{}}")) return EXIT_FAILURE; + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"alive\":true}}", + (unsigned long long)call_id); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + continue; + } else if (mode == MODE_INVALID_NOTIFICATION) { + yyjson_doc_free(document); + if (!write_frame("{\"jsonrpc\":\"2.0\",\"method\":\"future.notice\",\"params\":{},\"extra\":true}")) return EXIT_FAILURE; + continue; + } else { + length = snprintf(response, sizeof(response), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"result\":{\"echo\":true}}", + (unsigned long long)yyjson_get_uint(id)); + } + yyjson_doc_free(document); + if (length <= 0 || (size_t)length >= sizeof(response) || !write_frame(response)) return EXIT_FAILURE; + if (mode == MODE_STALL_AFTER_INIT) { + pause_for_backpressure(); + return EXIT_SUCCESS; + } + } +} diff --git a/libsecretspec-resolver/tests/regressions.c b/libsecretspec-resolver/tests/regressions.c new file mode 100644 index 000000000..4578a0fba --- /dev/null +++ b/libsecretspec-resolver/tests/regressions.c @@ -0,0 +1,565 @@ +#ifndef _WIN32 +#define _POSIX_C_SOURCE 200809L +#endif + +#include "secretspec_resolver.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#endif + +static const char client_initialize[] = + "{\"protocol\":\"secretspec.resolver\",\"versions\":[1]," + "\"client\":{\"name\":\"c-test\",\"version\":\"1\"}," + "\"limits\":{\"max_frame_bytes\":32768,\"max_in_flight\":4}," + "\"application\":{}}"; + +static uint64_t now_ms(void) { + struct timespec time; + if (timespec_get(&time, TIME_UTC) != TIME_UTC) return 0; + return (uint64_t)time.tv_sec * UINT64_C(1000) + + (uint64_t)time.tv_nsec / UINT64_C(1000000); +} + +static void pause_ms(uint64_t milliseconds) { +#ifdef _WIN32 + Sleep(milliseconds > MAXDWORD ? MAXDWORD : (DWORD)milliseconds); +#else + struct timespec delay; + delay.tv_sec = (time_t)(milliseconds / UINT64_C(1000)); + delay.tv_nsec = (long)((milliseconds % UINT64_C(1000)) * UINT64_C(1000000)); + (void)nanosleep(&delay, NULL); +#endif +} + +static void ss_reset(secretspec_resolver_buffer *buffer) { + buffer->data = NULL; + buffer->size = 0; +} + +static secretspec_resolver_slice slice(const char *text) { + secretspec_resolver_slice value; + value.data = (const unsigned char *)text; + value.size = strlen(text); + return value; +} + +static void set_options( + secretspec_resolver_options *options, + const char *peer, + const char *mode, + const char *initialize) { + static secretspec_resolver_slice arguments[1]; + memset(options, 0, sizeof(*options)); + arguments[0] = slice(mode); + options->struct_size = sizeof(*options); + options->abi_version = SECRETSPEC_RESOLVER_ABI_VERSION; + options->executable = slice(peer); + options->arguments = arguments; + options->argument_count = 1; + options->initialize_params_json = slice(initialize); + options->max_stderr_bytes = 4096; +} + +static int open_client( + const char *peer, + const char *mode, + secretspec_resolver_client **client, + secretspec_resolver_buffer *error) { + secretspec_resolver_options options; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_status status; + set_options(&options, peer, mode, client_initialize); + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), client, &server, error); + secretspec_resolver_buffer_free(server); + return status == SECRETSPEC_RESOLVER_OK; +} + +/* CreateProcessW requires a custom environment block to be sorted by variable + * name without regard to case. Supply overrides in the opposite order and let + * the child inspect the block it actually received. The value assertions run + * on every platform; Windows additionally verifies the native ordering. */ +static int launches_with_environment(const char *peer, int inherit) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + static secretspec_resolver_slice environment[3]; + environment[0] = slice("SECRETSPEC_Z_LAST=last"); + environment[1] = slice("SecretSpec_M_Middle=middle"); + environment[2] = slice("secretspec_a_first=first"); + set_options(&options, peer, "--check-environment", client_initialize); + if (inherit) options.flags |= SECRETSPEC_RESOLVER_INHERIT_ENVIRONMENT; + options.environment = environment; + options.environment_count = 3; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + status = secretspec_resolver_client_close( + client, now_ms() + UINT64_C(2000), &error); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + return status == SECRETSPEC_RESOLVER_OK; +failed: + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; +} + +static int launches_with_a_sorted_environment(const char *peer) { + return launches_with_environment(peer, 1) && + launches_with_environment(peer, 0); +} + +static int rejects_bad_shutdown(const char *peer) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + if (!open_client(peer, "--bad-shutdown", &client, &error)) goto failed; + status = secretspec_resolver_client_close( + client, now_ms() + UINT64_C(2000), &error); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + return status == SECRETSPEC_RESOLVER_PROTOCOL; +failed: + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; +} + +static int freed_calls_expire(const char *peer) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + size_t index; + if (!open_client(peer, "--ignore-calls", &client, &error)) goto failed; + for (index = 0; index < 4; index++) { + static const unsigned char params[] = "{}"; + uint64_t deadline = now_ms() + UINT64_C(100); + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, deadline, &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + secretspec_resolver_call_free(call); + call = NULL; + } + pause_ms(UINT64_C(400)); + { + static const unsigned char params[] = "{}"; + uint64_t deadline = now_ms() + UINT64_C(1000); + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, deadline, &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + } + secretspec_resolver_call_free(call); + call = NULL; + status = secretspec_resolver_client_close( + client, now_ms() + UINT64_C(2000), &error); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + return status == SECRETSPEC_RESOLVER_OK; +failed: + secretspec_resolver_buffer_free(error); + if (call != NULL) secretspec_resolver_call_free(call); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; +} + +static int descendant_pipes_do_not_block_close(const char *peer) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + uint64_t started; + if (!open_client(peer, "--descendant-holds-pipes", &client, &error)) goto failed; + started = now_ms(); + status = secretspec_resolver_client_close( + client, started + UINT64_C(250), &error); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + return status == SECRETSPEC_RESOLVER_OK && now_ms() - started < UINT64_C(2000); +failed: + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; +} + +/* An endpoint that prints a banner on the stream reserved for frames must be + * named as such. Reporting it as a frame-size problem is what sends integrators + * hunting a bug that is not there, so the diagnostic is pinned here and in the + * Rust decoder's matching test. */ +static int names_non_protocol_text(const char *peer) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + int named; + set_options(&options, peer, "--banner-on-stdout", client_initialize); + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + named = error.data != NULL && + strstr((const char *)error.data, "non-protocol text") != NULL; + secretspec_resolver_buffer_free(server); + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return status == SECRETSPEC_RESOLVER_PROTOCOL && client == NULL && named; +} + +/* An error code and kind from a later revision of the protocol must reach the + * caller as an ordinary remote failure. Refusing it would kill the session, and + * the error set could then never grow without a new protocol version. Pinned + * here and in the Rust decoder's matching test. */ +static int a_future_error_kind_does_not_kill_the_session(const char *peer) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + secretspec_resolver_status status; + static const unsigned char params[] = "{}"; + int reported; + if (!open_client(peer, "--future-error-kind", &client, &error)) goto failed; + status = secretspec_resolver_client_call( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(2000), &result, &error); + reported = status == SECRETSPEC_RESOLVER_REMOTE_ERROR; + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + error.data = NULL; + error.size = 0; + /* The session survived, so an ordinary shutdown still works. */ + status = secretspec_resolver_client_close(client, now_ms() + UINT64_C(2000), &error); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + return reported && status == SECRETSPEC_RESOLVER_OK; +failed: + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; +} + +/* The prompt loop end to end: the peer asks mid-call, the caller answers + * without any callback into this library, and the call completes with the + * answered value. The peer's prompt deliberately uses request ID 1, which the + * client also used for its own initialize, so this also pins that the two + * directions have separate ID spaces. */ +static int answers_a_prompt_and_completes_the_call(const char *peer) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_prompt *prompt = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + static const unsigned char params[] = "{}"; + static const unsigned char answer[] = "typed-by-a-person"; + static const unsigned char invalid_answer[] = {0xc3, 0x28}; + secretspec_resolver_status status; + secretspec_resolver_slice asked; + int outcome = 0; + + set_options(&options, peer, "--prompt", client_initialize); + options.flags |= SECRETSPEC_RESOLVER_ANSWER_PROMPTS; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(5000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + + /* The one-shot form cannot resume after a prompt and must say so. */ + status = secretspec_resolver_client_call( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(5000), &result, &error); + if (status != SECRETSPEC_RESOLVER_INVALID_ARGUMENT) goto done; + secretspec_resolver_buffer_free(result); + ss_reset(&result); + secretspec_resolver_buffer_free(error); + ss_reset(&error); + + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(5000), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + + status = secretspec_resolver_call_wait(call, &result, &error); + if (status != SECRETSPEC_RESOLVER_PROMPT_PENDING) goto done; + if (secretspec_resolver_prompt_take(client, &prompt, &error) != SECRETSPEC_RESOLVER_OK || + prompt == NULL) goto done; + asked = secretspec_resolver_prompt_params(prompt); + if (asked.data == NULL || + strstr((const char *)asked.data, "DEPLOY_PASSWORD") == NULL || + strstr((const char *)asked.data, "\"profile\"") == NULL) goto done; + /* An empty answer is refused; declining is the way to say no. */ + if (secretspec_resolver_prompt_answer(prompt, answer, 0, &error) != + SECRETSPEC_RESOLVER_INVALID_ARGUMENT) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + /* Invalid UTF-8 is rejected before the one-shot prompt is consumed. */ + if (secretspec_resolver_prompt_answer(prompt, invalid_answer, sizeof(invalid_answer), &error) != + SECRETSPEC_RESOLVER_INVALID_ARGUMENT) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + if (secretspec_resolver_prompt_answer(prompt, answer, sizeof(answer) - 1, &error) != + SECRETSPEC_RESOLVER_OK) goto done; + /* One prompt owes exactly one response. */ + if (secretspec_resolver_prompt_answer(prompt, answer, sizeof(answer) - 1, &error) != + SECRETSPEC_RESOLVER_INVALID_ARGUMENT) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + secretspec_resolver_prompt_free(prompt); + prompt = NULL; + + status = secretspec_resolver_call_wait(call, &result, &error); + if (status != SECRETSPEC_RESOLVER_OK || result.data == NULL) goto done; + outcome = strstr((const char *)result.data, "typed-by-a-person") != NULL; +done: + if (prompt != NULL) secretspec_resolver_prompt_free(prompt); + if (call != NULL) secretspec_resolver_call_free(call); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) { + secretspec_resolver_buffer close_error = {NULL, 0}; + (void)secretspec_resolver_client_close(client, now_ms() + UINT64_C(2000), &close_error); + secretspec_resolver_buffer_free(close_error); + secretspec_resolver_client_free(client); + } + return outcome; +} + +static int an_expired_prompt_does_not_block_later_calls(const char *peer) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_prompt *prompt = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + static const unsigned char params[] = "{}"; + secretspec_resolver_status status; + int outcome = 0; + + set_options(&options, peer, "--expired-prompt", client_initialize); + options.flags |= SECRETSPEC_RESOLVER_ANSWER_PROMPTS; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(200), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + if (secretspec_resolver_call_wait(call, &result, &error) != + SECRETSPEC_RESOLVER_PROMPT_PENDING) goto done; + pause_ms(UINT64_C(300)); + if (secretspec_resolver_call_wait(call, &result, &error) != + SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + secretspec_resolver_call_free(call); + call = NULL; + + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(2000), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + status = secretspec_resolver_call_wait(call, &result, &error); + if (status != SECRETSPEC_RESOLVER_OK || result.data == NULL) goto done; + if (secretspec_resolver_prompt_take(client, &prompt, &error) != + SECRETSPEC_RESOLVER_OK || prompt != NULL) goto done; + outcome = 1; +done: + if (prompt != NULL) secretspec_resolver_prompt_free(prompt); + if (call != NULL) secretspec_resolver_call_free(call); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) { + secretspec_resolver_buffer close_error = {NULL, 0}; + (void)secretspec_resolver_client_close(client, now_ms() + UINT64_C(2000), &close_error); + secretspec_resolver_buffer_free(close_error); + secretspec_resolver_client_free(client); + } + return outcome; +} + +static int an_answer_cannot_outlive_its_prompt(const char *peer) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_prompt *prompt = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + static const unsigned char params[] = "{}"; + static const unsigned char answer[] = "too-late"; + secretspec_resolver_status status; + int outcome = 0; + + set_options(&options, peer, "--expired-prompt", client_initialize); + options.flags |= SECRETSPEC_RESOLVER_ANSWER_PROMPTS; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(250), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + if (secretspec_resolver_call_wait(call, &result, &error) != + SECRETSPEC_RESOLVER_PROMPT_PENDING) goto done; + if (secretspec_resolver_prompt_take(client, &prompt, &error) != + SECRETSPEC_RESOLVER_OK || prompt == NULL) goto done; + pause_ms(UINT64_C(150)); + if (secretspec_resolver_prompt_answer(prompt, answer, sizeof(answer) - 1, &error) != + SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + pause_ms(UINT64_C(150)); + outcome = secretspec_resolver_call_wait(call, &result, &error) == + SECRETSPEC_RESOLVER_DEADLINE_EXCEEDED; +done: + if (prompt != NULL) secretspec_resolver_prompt_free(prompt); + if (call != NULL) secretspec_resolver_call_free(call); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) { + secretspec_resolver_buffer close_error = {NULL, 0}; + (void)secretspec_resolver_client_close(client, now_ms() + UINT64_C(2000), &close_error); + secretspec_resolver_buffer_free(close_error); + secretspec_resolver_client_free(client); + } + return outcome; +} + +static int a_prompt_cannot_outlive_its_parent(const char *peer) { + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_prompt *prompt = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + static const unsigned char params[] = "{}"; + static const unsigned char answer[] = "too-late"; + secretspec_resolver_status status; + int outcome = 0; + + set_options(&options, peer, "--parent-terminal-prompt", client_initialize); + options.flags |= SECRETSPEC_RESOLVER_ANSWER_PROMPTS; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(2000), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + if (secretspec_resolver_call_wait(call, &result, &error) != + SECRETSPEC_RESOLVER_PROMPT_PENDING) goto done; + if (secretspec_resolver_prompt_take(client, &prompt, &error) != + SECRETSPEC_RESOLVER_OK || prompt == NULL) goto done; + pause_ms(UINT64_C(200)); + if (secretspec_resolver_prompt_answer(prompt, answer, sizeof(answer) - 1, &error) != + SECRETSPEC_RESOLVER_CANCELLED) goto done; + secretspec_resolver_buffer_free(error); + ss_reset(&error); + outcome = secretspec_resolver_call_wait(call, &result, &error) == + SECRETSPEC_RESOLVER_OK; +done: + if (prompt != NULL) secretspec_resolver_prompt_free(prompt); + if (call != NULL) secretspec_resolver_call_free(call); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) { + secretspec_resolver_buffer close_error = {NULL, 0}; + (void)secretspec_resolver_client_close(client, now_ms() + UINT64_C(500), &close_error); + secretspec_resolver_buffer_free(close_error); + secretspec_resolver_client_free(client); + } + return outcome; +} + +static int rejects_a_callback_deadline_after_its_parent(const char *peer) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_call *call = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + static const unsigned char params[] = "{}"; + secretspec_resolver_status status; + int outcome = 0; + secretspec_resolver_options options; + secretspec_resolver_buffer server = {NULL, 0}; + + set_options(&options, peer, "--late-deadline-prompt", client_initialize); + options.flags |= SECRETSPEC_RESOLVER_ANSWER_PROMPTS; + status = secretspec_resolver_client_open( + &options, now_ms() + UINT64_C(2000), &client, &server, &error); + secretspec_resolver_buffer_free(server); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + status = secretspec_resolver_call_start( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(1000), &call, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto done; + status = secretspec_resolver_call_wait(call, &result, &error); + outcome = status == SECRETSPEC_RESOLVER_PROTOCOL; +done: + if (call != NULL) secretspec_resolver_call_free(call); + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return outcome; +} + +static int notification_semantics_are_consistent(const char *peer) { + static const unsigned char params[] = "{}"; + const char *modes[] = {"--unknown-notification", "--invalid-notification"}; + size_t index; + for (index = 0; index < sizeof(modes) / sizeof(modes[0]); index++) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + secretspec_resolver_status status; + if (!open_client(peer, modes[index], &client, &error)) goto failed; + status = secretspec_resolver_client_call( + client, (const unsigned char *)"resolver.get", strlen("resolver.get"), + params, sizeof(params) - 1, now_ms() + UINT64_C(1000), &result, &error); + if ((index == 0 && status != SECRETSPEC_RESOLVER_OK) || + (index == 1 && status != SECRETSPEC_RESOLVER_PROTOCOL)) goto failed; + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + secretspec_resolver_client_free(client); + continue; +failed: + secretspec_resolver_buffer_free(result); + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return 0; + } + return 1; +} + +int main(int argc, char **argv) { + if (argc != 2) return EXIT_FAILURE; + if (!launches_with_a_sorted_environment(argv[1])) return EXIT_FAILURE; + if (!names_non_protocol_text(argv[1])) return EXIT_FAILURE; + if (!answers_a_prompt_and_completes_the_call(argv[1])) return EXIT_FAILURE; + if (!an_expired_prompt_does_not_block_later_calls(argv[1])) return EXIT_FAILURE; + if (!an_answer_cannot_outlive_its_prompt(argv[1])) return EXIT_FAILURE; + if (!a_prompt_cannot_outlive_its_parent(argv[1])) return EXIT_FAILURE; + if (!rejects_a_callback_deadline_after_its_parent(argv[1])) return EXIT_FAILURE; + if (!notification_semantics_are_consistent(argv[1])) return EXIT_FAILURE; + if (!a_future_error_kind_does_not_kill_the_session(argv[1])) return EXIT_FAILURE; + if (!rejects_bad_shutdown(argv[1])) return EXIT_FAILURE; + if (!freed_calls_expire(argv[1])) return EXIT_FAILURE; + if (!descendant_pipes_do_not_block_close(argv[1])) return EXIT_FAILURE; + return EXIT_SUCCESS; +} diff --git a/libsecretspec-resolver/tests/session.c b/libsecretspec-resolver/tests/session.c new file mode 100644 index 000000000..27f5ae985 --- /dev/null +++ b/libsecretspec-resolver/tests/session.c @@ -0,0 +1,72 @@ +#include "secretspec_resolver.h" + +#include +#include +#include +#include +#include + +static uint64_t now_ms(void) { + struct timespec time; + if (timespec_get(&time, TIME_UTC) != TIME_UTC) return 0; + return (uint64_t)time.tv_sec * UINT64_C(1000) + (uint64_t)time.tv_nsec / UINT64_C(1000000); +} + +static secretspec_resolver_slice slice(const char *text) { + secretspec_resolver_slice value; + value.data = (const unsigned char *)text; + value.size = strlen(text); + return value; +} + +int main(int argc, char **argv) { + static const char initialize[] = + "{\"protocol\":\"secretspec.resolver\",\"versions\":[1]," + "\"client\":{\"name\":\"c-test\",\"version\":\"1\"}," + "\"limits\":{\"max_frame_bytes\":32768,\"max_in_flight\":4}," + "\"application\":{}}"; + secretspec_resolver_options options; + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer result = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status; + uint64_t deadline; + static const unsigned char params[] = "{}"; + + if (argc != 2) return EXIT_FAILURE; + memset(&options, 0, sizeof(options)); + options.struct_size = sizeof(options); + options.abi_version = SECRETSPEC_RESOLVER_ABI_VERSION; + options.executable = slice(argv[1]); + options.initialize_params_json.data = (const unsigned char *)initialize; + options.initialize_params_json.size = sizeof(initialize) - 1; + options.max_stderr_bytes = 4096; + deadline = now_ms() + 2000; + status = secretspec_resolver_client_open(&options, deadline, &client, &server, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + secretspec_resolver_buffer_free(server); + deadline = now_ms() + 2000; + status = secretspec_resolver_client_call(client, + (const unsigned char *)"resolver.get", + strlen("resolver.get"), + params, + sizeof(params) - 1, + deadline, + &result, + &error); + if (status != SECRETSPEC_RESOLVER_OK || result.size != strlen("{\"echo\":true}") || + memcmp(result.data, "{\"echo\":true}", result.size) != 0) goto failed; + secretspec_resolver_buffer_free(result); + status = secretspec_resolver_client_close(client, now_ms() + 2000, &error); + if (status != SECRETSPEC_RESOLVER_OK) goto failed; + secretspec_resolver_client_free(client); + return EXIT_SUCCESS; + +failed: + if (error.data != NULL) fwrite(error.data, 1, error.size, stderr); + secretspec_resolver_buffer_free(error); + secretspec_resolver_buffer_free(result); + if (client != NULL) secretspec_resolver_client_free(client); + return EXIT_FAILURE; +} diff --git a/libsecretspec-resolver/tests/smoke.c b/libsecretspec-resolver/tests/smoke.c new file mode 100644 index 000000000..ab432cd92 --- /dev/null +++ b/libsecretspec-resolver/tests/smoke.c @@ -0,0 +1,57 @@ +#include "secretspec_resolver.h" + +#include +#include +#include + +static secretspec_resolver_slice slice(const char *text) { + secretspec_resolver_slice value; + value.data = (const unsigned char *)text; + value.size = strlen(text); + return value; +} + +static int rejects(secretspec_resolver_options *options) { + secretspec_resolver_client *client = NULL; + secretspec_resolver_buffer server = {NULL, 0}; + secretspec_resolver_buffer error = {NULL, 0}; + secretspec_resolver_status status = secretspec_resolver_client_open( + options, UINT64_MAX, &client, &server, &error); + int valid = status == SECRETSPEC_RESOLVER_INVALID_ARGUMENT && client == NULL && + server.data == NULL && server.size == 0 && error.data != NULL; + secretspec_resolver_buffer_free(server); + secretspec_resolver_buffer_free(error); + if (client != NULL) secretspec_resolver_client_free(client); + return valid; +} + +int main(void) { + secretspec_resolver_buffer empty = {NULL, 0}; + secretspec_resolver_options options; + if (secretspec_resolver_abi_version() != SECRETSPEC_RESOLVER_ABI_VERSION) return EXIT_FAILURE; + secretspec_resolver_buffer_free(empty); + + memset(&options, 0, sizeof(options)); + if (!rejects(&options)) return EXIT_FAILURE; + + options.struct_size = sizeof(options); + options.abi_version = SECRETSPEC_RESOLVER_ABI_VERSION; + options.executable = slice("endpoint"); + options.initialize_params_json = slice("{}"); + options.reserved = 1; + if (!rejects(&options)) return EXIT_FAILURE; + + options.reserved = 0; + options.flags = UINT32_C(1) << 31; + if (!rejects(&options)) return EXIT_FAILURE; + + options.flags = 0; + options.executable.data = NULL; + options.executable.size = 1; + if (!rejects(&options)) return EXIT_FAILURE; + + options.executable = slice("endpoint"); + options.struct_size = sizeof(options) + 1; + if (!rejects(&options)) return EXIT_FAILURE; + return EXIT_SUCCESS; +} diff --git a/secretspec-ffi/Cargo.toml b/libsecretspec/Cargo.toml similarity index 63% rename from secretspec-ffi/Cargo.toml rename to libsecretspec/Cargo.toml index e9877f2aa..b16868c21 100644 --- a/secretspec-ffi/Cargo.toml +++ b/libsecretspec/Cargo.toml @@ -1,17 +1,21 @@ [package] -name = "secretspec-ffi" +name = "libsecretspec" version.workspace = true edition.workspace = true repository = "https://github.com/cachix/secretspec" description = "C ABI for SecretSpec: resolve secrets from any language" license = "Apache-2.0" +readme = "README.md" [lib] -name = "secretspec_ffi" -# cdylib for dynamic loading (the SDK distribution target); staticlib for -# embedding; lib (rlib) so Rust integration tests can call the entry points. -# All expose the same narrow C ABI. -crate-type = ["cdylib", "staticlib", "lib"] +# The public C artifact is `libsecretspec`. The dependency on the Rust +# `secretspec` crate is renamed below so this target can own that artifact name. +name = "secretspec" +doc = false +# cdylib for dynamic loading (the SDK distribution target) and staticlib for +# embedding. This package intentionally emits no Rust rlib: the workspace's +# Rust SDK already owns that artifact and crate name. +crate-type = ["cdylib", "staticlib"] # cargo-c enables this feature when building the C ABI; the ABI here is # unconditional, so it gates nothing. @@ -19,9 +23,9 @@ crate-type = ["cdylib", "staticlib", "lib"] capi = [] # Read by cargo-c when the static or shared library is installed with the C -# header and a generated secretspec_ffi.pc. +# header and a generated libsecretspec.pc. [package.metadata.capi.library] -name = "secretspec_ffi" +name = "secretspec" # The header in include/ is hand-maintained; consumers expect it at the # include root as #include "secretspec.h". @@ -33,14 +37,14 @@ subdirectory = "" asset = [{ from = "include/secretspec.h", to = "" }] [package.metadata.capi.pkg_config] -name = "secretspec_ffi" -filename = "secretspec_ffi" +name = "libsecretspec" +filename = "libsecretspec" description = "C ABI for SecretSpec: resolve secrets from any language" [dependencies] # The envelope/resolve logic lives in the secretspec crate (resolve_json); this # crate is only the C ABI wrapper. -secretspec = { workspace = true } +secretspec-core.workspace = true [dev-dependencies] serde_json = { workspace = true } diff --git a/libsecretspec/README.md b/libsecretspec/README.md new file mode 100644 index 000000000..845bd5893 --- /dev/null +++ b/libsecretspec/README.md @@ -0,0 +1,52 @@ +# libsecretspec + +`libsecretspec` is SecretSpec's embedded C ABI. It links the Rust resolver and +all enabled in-tree providers into a shared or static native library, exposing +three JSON-in/JSON-out functions through +[`include/secretspec.h`](include/secretspec.h). + +The public name is `libsecretspec` starting with SecretSpec 0.20. Earlier +releases called this component `secretspec-ffi` and emitted library filenames +containing `secretspec_ffi`; the 0.20 SDK runtime loaders continue to recognize +those older shared-library filenames. + +The rename changes packaging, not the three exported C symbols or their +ownership rules. Compatibility is intentionally asymmetric: + +| Consumer | Pre-0.20 native library with a 0.20 SDK | 0.20 native library with a pre-0.20 SDK | +| --- | --- | --- | +| Go purego, .NET, PHP FFI | Supported by legacy filename fallback; only pre-0.20 behavior is available | Requires the old filename or an explicit `SECRETSPEC_FFI_LIB` path | +| Ruby native extension | New source builds accept `libsecretspec_ffi.a`; an already-built extension is unaffected | A pre-0.20 source build still looks for the old archive name | +| Go cgo/pkg-config and Haskell source builds | Rebuild against `libsecretspec.pc` | Rebuild or provide compatibility pkg-config/archive names | +| Python, Node, Swift, packaged .NET/PHP/Ruby artifacts | Native code is bundled or linked by the package; runtime filename discovery does not apply | Upgrade the package and native artifact together | + +Filename fallback is not feature emulation: an older library cannot implement +0.20 request fields or behavior. SDK schema/ABI checks still decide whether a +particular old library is usable. `SECRETSPEC_FFI_LIB` keeps its established +environment-variable spelling across the rename. + +Public artifacts are: + +- `libsecretspec.so`, `libsecretspec.dylib`, or Cargo's `secretspec.dll` + (some SDK packages stage the Windows DLL as `libsecretspec.dll`); +- `libsecretspec.a`, or the platform-equivalent static library, for static + embedding; +- `secretspec.h`; and +- `libsecretspec.pc` when installed with cargo-c. + +Build the native library directly with: + +```console +cargo build -p libsecretspec --release +``` + +Or install one library form, its header, and pkg-config metadata: + +```console +bash libsecretspec/scripts/cinstall.sh "$PREFIX" static +bash libsecretspec/scripts/cinstall.sh "$PREFIX" shared +``` + +This is distinct from +[`libsecretspec-resolver`](../libsecretspec-resolver/), the pure-C client for +SecretSpec's out-of-process resolution protocol. diff --git a/secretspec-ffi/include/secretspec.h b/libsecretspec/include/secretspec.h similarity index 98% rename from secretspec-ffi/include/secretspec.h rename to libsecretspec/include/secretspec.h index d45c9f458..d446550ea 100644 --- a/secretspec-ffi/include/secretspec.h +++ b/libsecretspec/include/secretspec.h @@ -1,5 +1,5 @@ /* - * SecretSpec C ABI. + * libsecretspec: the embedded SecretSpec C ABI. * * A deliberately narrow, JSON-in / JSON-out boundary. The entire native surface * is the three functions below; all richness lives in the versioned JSON diff --git a/secretspec-ffi/scripts/cinstall.sh b/libsecretspec/scripts/cinstall.sh similarity index 93% rename from secretspec-ffi/scripts/cinstall.sh rename to libsecretspec/scripts/cinstall.sh index 3e8494902..de1cd77e4 100755 --- a/secretspec-ffi/scripts/cinstall.sh +++ b/libsecretspec/scripts/cinstall.sh @@ -33,7 +33,7 @@ case "$profile" in ;; esac -cargo cinstall -p secretspec-ffi --manifest-path "$repo_root/Cargo.toml" \ +cargo cinstall -p libsecretspec --manifest-path "$repo_root/Cargo.toml" \ --library-type "$library_type" \ --prefix "$prefix" \ --bindir lib \ diff --git a/secretspec-ffi/src/lib.rs b/libsecretspec/src/lib.rs similarity index 94% rename from secretspec-ffi/src/lib.rs rename to libsecretspec/src/lib.rs index af3e78f26..2af2d9e92 100644 --- a/secretspec-ffi/src/lib.rs +++ b/libsecretspec/src/lib.rs @@ -5,7 +5,7 @@ //! this ABI (Go via purego, Ruby via ffi, Haskell via the GHC FFI) stays a //! thin shell: marshal a request string in, get a response string out, free it. //! Python (pyo3) and Node (napi-rs) skip this C ABI and call -//! `secretspec::resolve_json` directly, but share the same JSON envelope +//! `secretspec_core::resolve_json` directly, but share the same JSON envelope //! contract. Resolution logic lives only in the `secretspec` crate; this is a //! wrapper. //! @@ -34,9 +34,9 @@ //! //! `mode` selects which shape comes back, and defaults to `"resolve"`: //! -//! - `"resolve"` — the value-carrying [`secretspec::ResolveResponse`]. Set +//! - `"resolve"` — the value-carrying [`secretspec_core::ResolveResponse`]. Set //! `no_values` to strip the values from it. -//! - `"report"` — the value-free [`secretspec::ResolutionReport`]: the +//! - `"report"` — the value-free [`secretspec_core::ResolutionReport`]: the //! inventory/preflight view the CLI exposes as `check --json`. //! //! Any other value is rejected with an `invalid_request` error. @@ -78,6 +78,9 @@ //! the boundary (only the temp-file path does). use std::ffi::{CStr, CString, c_char}; + +#[cfg(test)] +mod tests; use std::panic::{AssertUnwindSafe, catch_unwind}; /// ABI version, NUL-terminated for direct return as a C string. @@ -144,7 +147,7 @@ fn resolve_inner(request_json: *const c_char) -> String { // Safety: caller contract guarantees a NUL-terminated string when non-null. let raw = unsafe { CStr::from_ptr(request_json) }; match raw.to_str() { - Ok(text) => secretspec::resolve_json(text), + Ok(text) => secretspec_core::resolve_json(text), Err(_) => input_error("request_json was not valid UTF-8"), } } diff --git a/secretspec-ffi/tests/c_abi.rs b/libsecretspec/src/tests.rs similarity index 99% rename from secretspec-ffi/tests/c_abi.rs rename to libsecretspec/src/tests.rs index 32b21e349..ad7750eec 100644 --- a/secretspec-ffi/tests/c_abi.rs +++ b/libsecretspec/src/tests.rs @@ -5,7 +5,7 @@ use std::ffi::{CStr, CString, c_char}; use std::fs; -use secretspec_ffi::{secretspec_abi_version, secretspec_free, secretspec_resolve}; +use crate::{secretspec_abi_version, secretspec_free, secretspec_resolve}; use serde_json::Value; use tempfile::TempDir; diff --git a/secretspec-ffi/tests/smoke.c b/libsecretspec/tests/smoke.c similarity index 92% rename from secretspec-ffi/tests/smoke.c rename to libsecretspec/tests/smoke.c index 42c3437e4..3714fa3b7 100644 --- a/secretspec-ffi/tests/smoke.c +++ b/libsecretspec/tests/smoke.c @@ -1,5 +1,5 @@ /* - * Minimal C smoke test for the SecretSpec C ABI. Proves the cdylib links, the + * Minimal C smoke test for libsecretspec. Proves the cdylib links, the * three entry points are callable from C, and the malloc/free roundtrip works. * Run by the ffi-build workflow against the freshly built library. */ diff --git a/schema/ipc/v1/common.schema.json b/schema/ipc/v1/common.schema.json new file mode 100644 index 000000000..d10ec6470 --- /dev/null +++ b/schema/ipc/v1/common.schema.json @@ -0,0 +1,294 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "title": "SecretSpec IPC version 1 common types", + "$defs": { + "RequestId": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "DeadlineUnixMs": { + "type": "integer", + "minimum": 0, + "maximum": 18446744073709551615 + }, + "Methods": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "Capabilities": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "DiscoveryDocument": { + "description": "A self-contained OpenRPC document. The x-secretspec extension reports endpoint-specific discovery metadata.", + "type": "object", + "required": ["openrpc", "info", "methods", "components", "x-secretspec"], + "properties": { + "openrpc": { "type": "string", "minLength": 1 }, + "info": { "type": "object" }, + "methods": { "type": "array" }, + "components": { "type": "object" }, + "x-secretspec": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "server", "methods", "absolute_max_frame_bytes"], + "properties": { + "protocol": { + "type": "string", + "enum": ["secretspec.resolver", "secretspec.provider"] + }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1 } + }, + "server": { "$ref": "#/$defs/Product" }, + "methods": { "$ref": "#/$defs/Methods" }, + "absolute_max_frame_bytes": { + "type": "integer", + "const": 1048576 + } + } + } + } + }, + "Meta": { + "type": "object", + "additionalProperties": false, + "required": ["deadline_unix_ms"], + "properties": { + "deadline_unix_ms": { "$ref": "#/$defs/DeadlineUnixMs" }, + "parent_request_id": { "$ref": "#/$defs/RequestId" } + } + }, + "Product": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "version": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "Limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_frame_bytes", "max_in_flight"], + "properties": { + "max_frame_bytes": { + "type": "integer", + "minimum": 4096, + "maximum": 1048576 + }, + "max_in_flight": { + "type": "integer", + "minimum": 1, + "maximum": 32 + } + } + }, + "ErrorKind": { + "description": "Kinds a conforming version 1 sender emits. Closed for senders, open for receivers: a receiver decodes a value outside this set as an unnamed failure rather than rejecting the frame, so a later revision can add one without a new protocol version.", + "type": "string", + "enum": [ + "parse_error", + "invalid_request", + "method_not_found", + "invalid_params", + "internal", + "unsupported_version", + "capability_required", + "deadline_exceeded", + "cancelled", + "unavailable", + "permission_denied", + "interaction_required", + "conflict", + "operation_failed", + "message_too_large", + "representation_mismatch" + ] + }, + "ErrorData": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "retryable"], + "properties": { + "kind": { "$ref": "#/$defs/ErrorKind" }, + "retryable": { "type": "boolean" }, + "retry_after_ms": { + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + }, + "interaction": { "$ref": "#/$defs/InteractionReference" } + }, + "allOf": [ + { + "if": { "required": ["interaction"] }, + "then": { "properties": { "kind": { "const": "interaction_required" } } } + } + ] + }, + "InteractionReference": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "expires_at_unix_ms"], + "properties": { + "kind": { "type": "string", "enum": ["authorization"] }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "expires_at_unix_ms": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 18446744073709551615 + } + } + }, + "RpcError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "data"], + "properties": { + "code": { + "type": "integer", + "enum": [ + -32700, + -32600, + -32601, + -32602, + -32603, + -32000, + -32001, + -32002, + -32003, + -32004, + -32005, + -32006, + -32007, + -32008, + -32009, + -32010 + ] + }, + "message": { "type": "string", "minLength": 1, "maxLength": 256 }, + "data": { "$ref": "#/$defs/ErrorData" } + } + }, + "RequestEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "method", "_meta", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "method": { "type": "string", "minLength": 1, "maxLength": 256 }, + "_meta": { "$ref": "#/$defs/Meta" }, + "params": { "type": "object" } + } + }, + "NotificationEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { "type": "string", "minLength": 1, "maxLength": 256 }, + "params": { "type": "object" } + } + }, + "SuccessResponseEnvelope": { + "type": "object", + "additionalProperties": true, + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "result": {} + } + }, + "ErrorResponseEnvelope": { + "type": "object", + "additionalProperties": true, + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { + "oneOf": [ + { "$ref": "#/$defs/RequestId" }, + { "type": "null" } + ] + }, + "error": { "$ref": "#/$defs/RpcError" } + } + }, + "CancelParams": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/RequestId" } + } + }, + "EmptyParams": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "EmptyResult": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "Persistence": { + "type": "string", + "enum": ["persist", "ephemeral"] + }, + "Coordinates": { + "type": "object", + "additionalProperties": false, + "required": ["item"], + "properties": { + "item": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "field": { "type": ["string", "null"], "maxLength": 4096 }, + "vault": { "type": ["string", "null"], "maxLength": 4096 }, + "section": { "type": ["string", "null"], "maxLength": 4096 }, + "version": { "type": ["string", "null"], "maxLength": 4096 } + } + }, + "ConventionAddress": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "project", "profile", "key"], + "properties": { + "kind": { "const": "convention" }, + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 }, + "key": { "type": "string", "maxLength": 4096 } + } + }, + "NativeAddress": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "coordinates"], + "properties": { + "kind": { "const": "native" }, + "coordinates": { "$ref": "#/$defs/Coordinates" } + } + }, + "Address": { + "oneOf": [ + { "$ref": "#/$defs/ConventionAddress" }, + { "$ref": "#/$defs/NativeAddress" } + ] + } + } +} diff --git a/schema/ipc/v1/fixtures/provider/credential-request.json b/schema/ipc/v1/fixtures/provider/credential-request.json new file mode 100644 index 000000000..6f646a50b --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/credential-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"client.credential","_meta":{"deadline_unix_ms":1786766405000,"parent_request_id":2},"params":{"name":"access_token","scope":"example://account/team-a","required":true}} diff --git a/schema/ipc/v1/fixtures/provider/credential-result.json b/schema/ipc/v1/fixtures/provider/credential-result.json new file mode 100644 index 000000000..db3a7071c --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/credential-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"status":"found","value":"brokered credential"}} diff --git a/schema/ipc/v1/fixtures/provider/get-request.json b/schema/ipc/v1/fixtures/provider/get-request.json new file mode 100644 index 000000000..213f4f005 --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/get-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":3,"method":"provider.get","_meta":{"deadline_unix_ms":1786766405000},"params":{"address":{"kind":"native","coordinates":{"item":"database","field":"password"}}}} diff --git a/schema/ipc/v1/fixtures/provider/get-result.json b/schema/ipc/v1/fixtures/provider/get-result.json new file mode 100644 index 000000000..fa128599f --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/get-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":3,"result":{"status":"found","value":"IPC_CANARY_NOT_A_REAL_SECRET","expires_at_unix_ms":1786766460000}} diff --git a/schema/ipc/v1/fixtures/provider/initialize-request.json b/schema/ipc/v1/fixtures/provider/initialize-request.json new file mode 100644 index 000000000..5707d6428 --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/initialize-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"rpc.initialize","_meta":{"deadline_unix_ms":1786766405000},"params":{"protocol":"secretspec.provider","versions":[1],"client":{"name":"secretspec","version":"0.20.0"},"limits":{"max_frame_bytes":1048576,"max_in_flight":8},"client_methods":["client.credential"],"application":{"scheme":"factorseal","uri":"factorseal://default?namespace=cache","context":{"project":"fixture","profile":"production","base_dir":"/fixture/project","reason":"conformance test","requested_authorization_duration_ms":28800000}}}} diff --git a/schema/ipc/v1/fixtures/provider/initialize-result.json b/schema/ipc/v1/fixtures/provider/initialize-result.json new file mode 100644 index 000000000..8aa33007e --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/initialize-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocol":"secretspec.provider","version":1,"server":{"name":"factorseal-endpoint","version":"1.0.0"},"methods":["provider.resolve_address","provider.get","provider.set"],"capabilities":{},"limits":{"max_frame_bytes":1048576,"max_in_flight":8},"application":{"provider":{"name":"factorseal","display_uri":"factorseal://default","supported_coordinates":["field"],"generated_value_persistence":"persist","prompted_value_persistence":"persist","storage_identity":"factorseal://default","entry_container_identity":"factorseal://default","physical_store_path":null}}}} diff --git a/schema/ipc/v1/fixtures/provider/resolve-address-request.json b/schema/ipc/v1/fixtures/provider/resolve-address-request.json new file mode 100644 index 000000000..2183c2ec0 --- /dev/null +++ b/schema/ipc/v1/fixtures/provider/resolve-address-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"method":"provider.resolve_address","_meta":{"deadline_unix_ms":1786766405000},"params":{"address":{"kind":"convention","project":"payments","profile":"production","key":"DATABASE_PASSWORD"}}} diff --git a/schema/ipc/v1/fixtures/resolver/delete-request.json b/schema/ipc/v1/fixtures/resolver/delete-request.json new file mode 100644 index 000000000..8a79e7352 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/delete-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":5,"method":"resolver.delete","_meta":{"deadline_unix_ms":1786766408000},"params":{"name":"FORGE_TOKEN","purpose":{"consumer":"cargo","operation":"logout","host":"crates.io"}}} diff --git a/schema/ipc/v1/fixtures/resolver/delete-result.json b/schema/ipc/v1/fixtures/resolver/delete-result.json new file mode 100644 index 000000000..c53492b6d --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/delete-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":5,"result":{"status":"deleted","deleted":true,"target_provider":"keyring://"}} diff --git a/schema/ipc/v1/fixtures/resolver/get-request.json b/schema/ipc/v1/fixtures/resolver/get-request.json new file mode 100644 index 000000000..8fee16a77 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/get-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"method":"resolver.get","_meta":{"deadline_unix_ms":1786766405000},"params":{"name":"FORGE_TOKEN","representation":"value","purpose":{"consumer":"nix","operation":"fetch","host":"github.com","path":"/acme/project"}}} diff --git a/schema/ipc/v1/fixtures/resolver/get-value-result.json b/schema/ipc/v1/fixtures/resolver/get-value-result.json new file mode 100644 index 000000000..a6e477243 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/get-value-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"result":{"status":"resolved","representation":"value","value":"IPC_CANARY_NOT_A_REAL_SECRET","source":"provider","source_provider":"keyring://","expires_at_unix_ms":null,"refresh_at_unix_ms":null}} diff --git a/schema/ipc/v1/fixtures/resolver/initialize-request.json b/schema/ipc/v1/fixtures/resolver/initialize-request.json new file mode 100644 index 000000000..48ff31456 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/initialize-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"rpc.initialize","_meta":{"deadline_unix_ms":1786766405000},"params":{"protocol":"secretspec.resolver","versions":[1],"client":{"name":"conformance-client","version":"1.0.0"},"limits":{"max_frame_bytes":1048576,"max_in_flight":8},"application":{"manifest":{"kind":"path","path":"/fixture/secretspec.toml"},"provider":null,"profile":"production","scope":"deploy","reason":"conformance test","requested_authorization_duration_ms":28800000}}} diff --git a/schema/ipc/v1/fixtures/resolver/initialize-result.json b/schema/ipc/v1/fixtures/resolver/initialize-result.json new file mode 100644 index 000000000..7c86aa919 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/initialize-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocol":"secretspec.resolver","version":1,"server":{"name":"secretspec-resolver","version":"0.20.0"},"methods":["resolver.get","resolver.release"],"capabilities":{},"limits":{"max_frame_bytes":1048576,"max_in_flight":8},"application":{"manifest_kind":"path","supports_inline_manifest":true}}} diff --git a/schema/ipc/v1/fixtures/resolver/prompt-request.json b/schema/ipc/v1/fixtures/resolver/prompt-request.json new file mode 100644 index 000000000..92289aad4 --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/prompt-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"client.prompt","_meta":{"deadline_unix_ms":1786766405000,"parent_request_id":2},"params":{"name":"DEPLOY_PASSWORD","profile":"production","target_provider":"keyring://"}} diff --git a/schema/ipc/v1/fixtures/resolver/prompt-result.json b/schema/ipc/v1/fixtures/resolver/prompt-result.json new file mode 100644 index 000000000..6ef3bc32c --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/prompt-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"value":"entered by a person"}} diff --git a/schema/ipc/v1/fixtures/resolver/release-request.json b/schema/ipc/v1/fixtures/resolver/release-request.json new file mode 100644 index 000000000..1e556562b --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/release-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":3,"method":"resolver.release","_meta":{"deadline_unix_ms":1786766406000},"params":{"path_lease_ids":["Qk7jXGfOLpLzmvYxjOxvMw"]}} diff --git a/schema/ipc/v1/fixtures/resolver/set-request.json b/schema/ipc/v1/fixtures/resolver/set-request.json new file mode 100644 index 000000000..698eb3d7b --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/set-request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":4,"method":"resolver.set","_meta":{"deadline_unix_ms":1786766407000},"params":{"name":"FORGE_TOKEN","value":"IPC_CANARY_NOT_A_REAL_SECRET","purpose":{"consumer":"cargo","operation":"login","host":"crates.io"}}} diff --git a/schema/ipc/v1/fixtures/resolver/set-result.json b/schema/ipc/v1/fixtures/resolver/set-result.json new file mode 100644 index 000000000..1a8288c7c --- /dev/null +++ b/schema/ipc/v1/fixtures/resolver/set-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":4,"result":{"status":"stored","target_provider":"keyring://"}} diff --git a/schema/ipc/v1/fixtures/wire/cancel.json b/schema/ipc/v1/fixtures/wire/cancel.json new file mode 100644 index 000000000..0049563be --- /dev/null +++ b/schema/ipc/v1/fixtures/wire/cancel.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","method":"rpc.cancel","params":{"id":2}} diff --git a/schema/ipc/v1/fixtures/wire/discover.json b/schema/ipc/v1/fixtures/wire/discover.json new file mode 100644 index 000000000..090b1b419 --- /dev/null +++ b/schema/ipc/v1/fixtures/wire/discover.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"rpc.discover","_meta":{"deadline_unix_ms":1786766405000},"params":{}} diff --git a/schema/ipc/v1/fixtures/wire/discovery-result.json b/schema/ipc/v1/fixtures/wire/discovery-result.json new file mode 100644 index 000000000..0b1ff0b09 --- /dev/null +++ b/schema/ipc/v1/fixtures/wire/discovery-result.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"openrpc":"1.3.2","info":{"title":"SecretSpec Secret Resolution Protocol","version":"1"},"methods":[],"components":{},"x-secretspec":{"protocol":"secretspec.resolver","versions":[1],"server":{"name":"secretspec-resolver","version":"0.20.0"},"methods":["resolver.get","resolver.release"],"absolute_max_frame_bytes":1048576}}} diff --git a/schema/ipc/v1/fixtures/wire/error.json b/schema/ipc/v1/fixtures/wire/error.json new file mode 100644 index 000000000..b8f488d77 --- /dev/null +++ b/schema/ipc/v1/fixtures/wire/error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"error":{"code":-32006,"message":"interaction required","data":{"kind":"interaction_required","retryable":false,"interaction":{"kind":"authorization","id":"apr_7K3M","expires_at_unix_ms":1786766405000}}}} diff --git a/schema/ipc/v1/fixtures/wire/shutdown.json b/schema/ipc/v1/fixtures/wire/shutdown.json new file mode 100644 index 000000000..691261ddb --- /dev/null +++ b/schema/ipc/v1/fixtures/wire/shutdown.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":99,"method":"rpc.shutdown","_meta":{"deadline_unix_ms":1786766410000},"params":{}} diff --git a/schema/ipc/v1/provider.openrpc.json b/schema/ipc/v1/provider.openrpc.json new file mode 100644 index 000000000..4ac30183e --- /dev/null +++ b/schema/ipc/v1/provider.openrpc.json @@ -0,0 +1,96 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "SecretSpec Secret Provider Protocol", + "version": "1" + }, + "methods": [ + { + "name": "rpc.discover", + "summary": "Return this endpoint's self-contained OpenRPC description without initializing application state (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "OpenRPC document", "schema": { "$ref": "common.schema.json#/$defs/DiscoveryDocument" } } + }, + { + "name": "rpc.initialize", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/InitializeParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/InitializeResult" } } + }, + { + "name": "client.credential", + "summary": "Provider callback requesting one namespaced authentication credential", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/CredentialParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/CredentialResult" } } + }, + { + "name": "provider.resolve_address", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ResolveAddressResult" } } + }, + { + "name": "provider.get", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/GetResult" } } + }, + { + "name": "provider.get_many", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/GetManyParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/GetManyResult" } } + }, + { + "name": "provider.exists", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ExistsResult" } } + }, + { + "name": "provider.set", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/SetParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/StoredResult" } } + }, + { + "name": "provider.set_expiring", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/SetExpiringParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/StoredResult" } } + }, + { + "name": "provider.delete", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/DeletedResult" } } + }, + { + "name": "provider.clear", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/ClearParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ClearResult" } } + }, + { + "name": "provider.check_writable", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + }, + { + "name": "provider.check_deletable", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + }, + { + "name": "provider.describe_write_target", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/DescribeWriteTargetResult" } } + }, + { + "name": "provider.reflect", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/ReflectParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ReflectResult" } } + }, + { + "name": "rpc.cancel", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/CancelParams" } }], + "result": { "name": "notification", "schema": { "type": "null" } } + }, + { + "name": "rpc.shutdown", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + } + ] +} diff --git a/schema/ipc/v1/provider.schema.json b/schema/ipc/v1/provider.schema.json new file mode 100644 index 000000000..4cac698d9 --- /dev/null +++ b/schema/ipc/v1/provider.schema.json @@ -0,0 +1,338 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/provider.schema.json", + "title": "SecretSpec Secret Provider Protocol version 1", + "$defs": { + "InitializeApplication": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "uri", "context"], + "properties": { + "scheme": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "uri": { "type": "string", "minLength": 1, "maxLength": 32768 }, + "context": { "$ref": "#/$defs/ApplicationContext" } + } + }, + "CredentialParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "scope", "required"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$", "maxLength": 256 }, + "scope": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "required": { "type": "boolean" } + } + }, + "CredentialResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "value"], + "properties": { + "status": { "const": "found" }, + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "missing" } } + } + ] + }, + "ApplicationContext": { + "type": "object", + "additionalProperties": false, + "required": ["project", "profile", "base_dir", "reason"], + "properties": { + "project": { "type": ["string", "null"], "minLength": 1, "maxLength": 4096 }, + "profile": { "type": ["string", "null"], "minLength": 1, "maxLength": 4096 }, + "base_dir": { "type": ["string", "null"], "maxLength": 32768 }, + "reason": { "type": ["string", "null"], "maxLength": 4096 }, + "requested_authorization_duration_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "ProviderMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "display_uri", + "supported_coordinates", + "generated_value_persistence", + "prompted_value_persistence", + "storage_identity", + "entry_container_identity", + "physical_store_path" + ], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "display_uri": { "type": "string", "maxLength": 32768 }, + "supported_coordinates": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "enum": ["field", "vault", "section", "version"] } + }, + "generated_value_persistence": { "$ref": "common.schema.json#/$defs/Persistence" }, + "prompted_value_persistence": { "$ref": "common.schema.json#/$defs/Persistence" }, + "storage_identity": { "type": "string", "maxLength": 32768 }, + "entry_container_identity": { "type": "string", "maxLength": 32768 }, + "physical_store_path": { "type": ["string", "null"], "maxLength": 32768 } + } + }, + "InitializedApplication": { + "type": "object", + "additionalProperties": false, + "required": ["provider"], + "properties": { + "provider": { "$ref": "#/$defs/ProviderMetadata" } + } + }, + "InitializeParams": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "client", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.provider" }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 4294967295 } + }, + "client": { "$ref": "common.schema.json#/$defs/Product" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "client_methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "application": { "$ref": "#/$defs/InitializeApplication" } + } + }, + "InitializeResult": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "version", "server", "methods", "capabilities", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.provider" }, + "version": { "const": 1 }, + "server": { "$ref": "common.schema.json#/$defs/Product" }, + "methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "capabilities": { "$ref": "common.schema.json#/$defs/Capabilities" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "application": { "$ref": "#/$defs/InitializedApplication" } + } + }, + "AddressParams": { + "type": "object", + "additionalProperties": false, + "required": ["address"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" } + } + }, + "ResolveAddressResult": { + "type": "object", + "additionalProperties": false, + "required": ["coordinates"], + "properties": { + "coordinates": { "$ref": "common.schema.json#/$defs/Coordinates" } + } + }, + "GetResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "value", "expires_at_unix_ms"], + "properties": { + "status": { "const": "found" }, + "value": { "type": "string" }, + "expires_at_unix_ms": { + "description": "Authoritative time at which the secret itself expires, or null when the provider knows no bound. This is not provider or resolver cache freshness.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "missing" } } + } + ] + }, + "GetManyParams": { + "type": "object", + "additionalProperties": false, + "required": ["requests"], + "properties": { + "requests": { + "type": "array", + "maxItems": 1024, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "address"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "address": { "$ref": "common.schema.json#/$defs/Address" } + } + } + } + } + }, + "NamedGetResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["name", "status", "value", "expires_at_unix_ms"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "status": { "const": "found" }, + "value": { "type": "string" }, + "expires_at_unix_ms": { + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "status"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "status": { "const": "missing" } + } + } + ] + }, + "GetManyResult": { + "type": "object", + "additionalProperties": false, + "required": ["results"], + "properties": { + "results": { + "type": "array", + "maxItems": 1024, + "items": { "$ref": "#/$defs/NamedGetResult" } + } + } + }, + "ExistsResult": { + "type": "object", + "additionalProperties": false, + "required": ["exists"], + "properties": { "exists": { "type": "boolean" } } + }, + "SetParams": { + "type": "object", + "additionalProperties": false, + "required": ["address", "value"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" }, + "value": { "type": "string" } + } + }, + "SetExpiringParams": { + "type": "object", + "additionalProperties": false, + "required": ["address", "value", "ttl_ms"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" }, + "value": { "type": "string" }, + "ttl_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "StoredResult": { + "type": "object", + "additionalProperties": false, + "required": ["stored"], + "properties": { "stored": { "const": true } } + }, + "DeletedResult": { + "type": "object", + "additionalProperties": false, + "required": ["deleted"], + "properties": { "deleted": { "type": "boolean" } } + }, + "ClearScope": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { "kind": { "const": "all" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "project", "profile"], + "properties": { + "kind": { "const": "convention" }, + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 } + } + } + ] + }, + "ClearParams": { + "type": "object", + "additionalProperties": false, + "required": ["scope"], + "properties": { + "scope": { "$ref": "#/$defs/ClearScope" } + } + }, + "ClearResult": { + "type": "object", + "additionalProperties": false, + "required": ["cleared"], + "properties": { "cleared": { "type": "integer", "minimum": 0 } } + }, + "DescribeWriteTargetResult": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "properties": { "description": { "type": "string", "maxLength": 4096 } } + }, + "ReflectParams": { + "type": "object", + "additionalProperties": false, + "required": ["project", "profile"], + "properties": { + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 } + } + }, + "ReflectedDeclaration": { + "type": "object", + "additionalProperties": false, + "required": ["description", "required", "ref"], + "properties": { + "description": { "type": "string", "maxLength": 4096 }, + "required": { "type": "boolean" }, + "ref": { "$ref": "common.schema.json#/$defs/Coordinates" } + } + }, + "ReflectResult": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "declarations"], + "properties": { + "schema_version": { "const": 1 }, + "declarations": { + "type": "object", + "propertyNames": { "maxLength": 4096 }, + "additionalProperties": { "$ref": "#/$defs/ReflectedDeclaration" } + } + } + } + } +} diff --git a/schema/ipc/v1/resolver.openrpc.json b/schema/ipc/v1/resolver.openrpc.json new file mode 100644 index 000000000..2075d5b57 --- /dev/null +++ b/schema/ipc/v1/resolver.openrpc.json @@ -0,0 +1,72 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "SecretSpec Secret Resolution Protocol", + "version": "1" + }, + "methods": [ + { + "name": "rpc.discover", + "summary": "Return this endpoint's self-contained OpenRPC description without initializing application state (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "OpenRPC document", "schema": { "$ref": "common.schema.json#/$defs/DiscoveryDocument" } } + }, + { + "name": "rpc.initialize", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/InitializeParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/InitializeResult" } } + }, + { + "name": "resolver.get", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/GetParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/GetResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.release", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/ReleaseParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/ReleaseResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.set", + "summary": "Store one declared name. Optional: advertised only by endpoints that accept writes (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/SetParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/SetResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.delete", + "summary": "Remove one declared name's stored value. Optional, like resolver.set (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/DeleteParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/DeleteResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "client.prompt", + "summary": "Sent by the endpoint to its client: obtain one secret value from a person (0.20+). Only when the client advertised it in client_methods.", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/PromptParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/PromptResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "rpc.cancel", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/CancelParams" } }], + "result": { "name": "notification", "schema": { "type": "null" } } + }, + { + "name": "rpc.shutdown", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + } + ], + "components": { + "errors": { + "CommonError": { + "code": -32008, + "message": "operation failed", + "data": { "$ref": "common.schema.json#/$defs/ErrorData" } + } + } + } +} diff --git a/schema/ipc/v1/resolver.schema.json b/schema/ipc/v1/resolver.schema.json new file mode 100644 index 000000000..bea5ac266 --- /dev/null +++ b/schema/ipc/v1/resolver.schema.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/resolver.schema.json", + "title": "SecretSpec Secret Resolution Protocol version 1", + "$defs": { + "PathManifest": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path"], + "properties": { + "kind": { "const": "path" }, + "path": { "type": "string", "minLength": 1, "maxLength": 32768 } + } + }, + "InlineManifest": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "toml", "base_dir"], + "properties": { + "kind": { "const": "inline" }, + "toml": { "type": "string", "maxLength": 1048576 }, + "base_dir": { "type": "string", "minLength": 1, "maxLength": 32768 } + } + }, + "Manifest": { + "oneOf": [ + { "$ref": "#/$defs/PathManifest" }, + { "$ref": "#/$defs/InlineManifest" } + ] + }, + "InitializeApplication": { + "type": "object", + "additionalProperties": false, + "required": ["manifest", "provider", "profile", "scope", "reason"], + "properties": { + "manifest": { "$ref": "#/$defs/Manifest" }, + "provider": { "type": ["string", "null"], "maxLength": 32768 }, + "profile": { "type": ["string", "null"], "maxLength": 4096 }, + "scope": { "type": ["string", "null"], "maxLength": 4096 }, + "reason": { "type": ["string", "null"], "maxLength": 4096 }, + "requested_authorization_duration_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "InitializedApplication": { + "type": "object", + "additionalProperties": false, + "required": ["manifest_kind", "supports_inline_manifest"], + "properties": { + "manifest_kind": { "type": "string", "enum": ["path", "inline"] }, + "supports_inline_manifest": { "type": "boolean" } + } + }, + "InitializeParams": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "client", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.resolver" }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 4294967295 } + }, + "client": { "$ref": "common.schema.json#/$defs/Product" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "client_methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "application": { "$ref": "#/$defs/InitializeApplication" } + } + }, + "InitializeResult": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "version", "server", "methods", "capabilities", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.resolver" }, + "version": { "const": 1 }, + "server": { "$ref": "common.schema.json#/$defs/Product" }, + "methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "capabilities": { "$ref": "common.schema.json#/$defs/Capabilities" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "application": { "$ref": "#/$defs/InitializedApplication" } + } + }, + "Purpose": { + "type": "object", + "additionalProperties": false, + "required": ["consumer", "operation"], + "properties": { + "consumer": { "type": "string", "minLength": 1, "maxLength": 256 }, + "operation": { "type": "string", "minLength": 1, "maxLength": 256 }, + "host": { "type": "string", "maxLength": 4096 }, + "path": { "type": "string", "maxLength": 4096 } + } + }, + "GetParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "representation", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "representation": { "type": "string", "enum": ["auto", "value", "path"] }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "UndeclaredResult": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "undeclared" } } + }, + "MissingResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "required"], + "properties": { + "status": { "const": "missing" }, + "required": { "type": "boolean" } + } + }, + "ResolvedValueResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "representation", "value", "source", "expires_at_unix_ms", "refresh_at_unix_ms"], + "properties": { + "status": { "const": "resolved" }, + "representation": { "const": "value" }, + "value": { "type": "string" }, + "source": { "description": "Origins a conforming version 1 resolver emits. Closed for the resolver, open for the client, like ErrorKind.", "type": "string", "enum": ["provider", "generated", "default", "composed"] }, + "source_provider": { "type": "string", "maxLength": 32768 }, + "expires_at_unix_ms": { + "description": "Provider-reported time at which the secret itself expires. Null means no bound is known, not that the secret is permanent.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + }, + "refresh_at_unix_ms": { + "description": "When the resolver stops considering its cached copy current and will consult the authoritative route again. Null means this result was not served from a resolver cache.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + "ResolvedPathResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "representation", "path", "path_lease_id", "source", "expires_at_unix_ms", "refresh_at_unix_ms"], + "properties": { + "status": { "const": "resolved" }, + "representation": { "const": "path" }, + "path": { "type": "string", "minLength": 1, "maxLength": 32768 }, + "path_lease_id": { "type": "string", "minLength": 22, "maxLength": 256 }, + "source": { "description": "Origins a conforming version 1 resolver emits. Closed for the resolver, open for the client, like ErrorKind.", "type": "string", "enum": ["provider", "generated", "default", "composed"] }, + "source_provider": { "type": "string", "maxLength": 32768 }, + "expires_at_unix_ms": { + "description": "Provider-reported time at which the secret itself expires. Null means no bound is known, not that the secret is permanent.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + }, + "refresh_at_unix_ms": { + "description": "When the resolver stops considering its cached copy current and will consult the authoritative route again. Null means this result was not served from a resolver cache.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + "GetResult": { + "oneOf": [ + { "$ref": "#/$defs/UndeclaredResult" }, + { "$ref": "#/$defs/MissingResult" }, + { "$ref": "#/$defs/ResolvedValueResult" }, + { "$ref": "#/$defs/ResolvedPathResult" } + ] + }, + "PromptParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "profile"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "profile": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "PromptResult": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 } + } + }, + "SetParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "SetResult": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { "const": "stored" }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "DeleteParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "DeleteResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "deleted"], + "properties": { + "status": { "const": "deleted" }, + "deleted": { "type": "boolean" }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "ReleaseParams": { + "type": "object", + "additionalProperties": false, + "required": ["path_lease_ids"], + "properties": { + "path_lease_ids": { + "type": "array", + "maxItems": 256, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + }, + "ReleaseResult": { + "type": "object", + "additionalProperties": false, + "required": ["released"], + "properties": { + "released": { "type": "integer", "minimum": 0, "maximum": 256 } + } + } + } +} diff --git a/schema/resolve-response.schema.json b/schema/resolve-response.schema.json index a6aaef12c..b069e4c54 100644 --- a/schema/resolve-response.schema.json +++ b/schema/resolve-response.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://secretspec.dev/schema/resolve-response.schema.json", "title": "SecretSpec resolve response", - "description": "Value-carrying resolution result for one profile, emitted by the secretspec-ffi C ABI. CARRIES SECRET VALUES; treat as sensitive. When a required secret is missing, `secrets` is empty and `missing_required` is populated.", + "description": "Value-carrying resolution result for one profile, emitted by the libsecretspec C ABI. CARRIES SECRET VALUES; treat as sensitive. When a required secret is missing, `secrets` is empty and `missing_required` is populated.", "type": "object", "additionalProperties": false, "required": ["schema_version", "provider", "profile", "secrets", "missing_required", "missing_optional"], diff --git a/scripts/build-swift-xcframework.sh b/scripts/build-swift-xcframework.sh index 4e67475a3..c95eb61b3 100644 --- a/scripts/build-swift-xcframework.sh +++ b/scripts/build-swift-xcframework.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Package one or more macOS secretspec-ffi cdylibs as the CSecretSpec +# Package one or more macOS libsecretspec cdylibs as the CSecretSpec # XCFramework imported by the Swift SDK. Pass one native library per # architecture; multiple inputs are merged into one universal Mach-O because # xcodebuild rejects separate library definitions for the same platform. @@ -64,7 +64,7 @@ if [[ "$#" -eq 1 ]]; then else xcrun lipo -create "$@" -output "$staged_library" fi -cp "$repo_root/secretspec-ffi/include/secretspec.h" "$headers/" +cp "$repo_root/libsecretspec/include/secretspec.h" "$headers/" cp "$repo_root/secretspec-swift/ffi/module.modulemap" "$headers/" # A SwiftPM binary target embeds this dylib beside the consumer and supplies diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh index 4588af7a8..9db0ce762 100755 --- a/scripts/ci-sdks.sh +++ b/scripts/ci-sdks.sh @@ -20,7 +20,7 @@ echo "==> Building shared Rust SDK artifacts" # resolver dependency graph instead of serially rebuilding it for the FFI, # Node, and PHP packages after the language suites have started. cargo build \ - -p secretspec-ffi \ + -p libsecretspec \ -p secretspec \ -p secretspec-node-native \ -p secretspec-php-native @@ -29,12 +29,12 @@ target_dir="$(cargo metadata --no-deps --format-version 1 \ | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" case "$(uname -s)" in Darwin) - lib_name="libsecretspec_ffi.dylib" + lib_name="libsecretspec.dylib" node_native_name="libsecretspec_node_native.dylib" php_native_name="libsecretspec_php_native.dylib" ;; *) - lib_name="libsecretspec_ffi.so" + lib_name="libsecretspec.so" node_native_name="libsecretspec_node_native.so" php_native_name="libsecretspec_php_native.so" ;; @@ -46,11 +46,11 @@ export SECRETSPEC_BIN="$target_dir/debug/secretspec" # Raw linker flags for the legs that do not call pkg-config. A Rust staticlib # does not carry its own native dependency closure; NEVER hardcode this list -- # it drifts as providers change. -SECRETSPEC_FFI_NATIVE_LIBS="$(cargo rustc -q -p secretspec-ffi --crate-type staticlib -- \ +SECRETSPEC_FFI_NATIVE_LIBS="$(cargo rustc -q -p libsecretspec --crate-type staticlib -- \ --print native-static-libs 2>&1 | sed -n 's/^note: native-static-libs: //p' | tail -1)" export SECRETSPEC_FFI_NATIVE_LIBS -# Static-link contract: SDKs link libsecretspec_ffi.a (the resolver compiled in) +# Static-link contract: SDKs link libsecretspec.a (the resolver compiled in) # instead of dlopening the cdylib. Stage the artifacts Cargo already built into # a test-only prefix. Using cargo-c here would compile the full dependency graph # again under target/, adding several minutes without adding test @@ -61,12 +61,12 @@ export SECRETSPEC_FFI_PREFIX mkdir -p \ "$SECRETSPEC_FFI_PREFIX/lib/pkgconfig" \ "$SECRETSPEC_FFI_PREFIX/include" -ln -sfn "$target_dir/debug/libsecretspec_ffi.a" \ - "$SECRETSPEC_FFI_PREFIX/lib/libsecretspec_ffi.a" -ln -sfn "$repo_root/secretspec-ffi/include/secretspec.h" \ +ln -sfn "$target_dir/debug/libsecretspec.a" \ + "$SECRETSPEC_FFI_PREFIX/lib/libsecretspec.a" +ln -sfn "$repo_root/libsecretspec/include/secretspec.h" \ "$SECRETSPEC_FFI_PREFIX/include/secretspec.h" -ffi_version="$(cargo pkgid -p secretspec-ffi)" +ffi_version="$(cargo pkgid -p libsecretspec)" ffi_version="${ffi_version##*#}" { printf 'prefix=%s\n' "$SECRETSPEC_FFI_PREFIX" @@ -75,17 +75,17 @@ ffi_version="${ffi_version##*#}" 'libdir=${prefix}/lib' \ 'includedir=${prefix}/include' \ '' \ - 'Name: secretspec_ffi' \ + 'Name: libsecretspec' \ 'Description: C ABI for SecretSpec: resolve secrets from any language' printf 'Version: %s\n' "$ffi_version" - printf 'Libs: -L${libdir} -lsecretspec_ffi %s\n' "$SECRETSPEC_FFI_NATIVE_LIBS" + printf 'Libs: -L${libdir} -lsecretspec %s\n' "$SECRETSPEC_FFI_NATIVE_LIBS" printf 'Cflags: -I${includedir}\n' printf 'Libs.private: %s\n' "$SECRETSPEC_FFI_NATIVE_LIBS" -} > "$SECRETSPEC_FFI_PREFIX/lib/pkgconfig/secretspec_ffi.pc" +} > "$SECRETSPEC_FFI_PREFIX/lib/pkgconfig/libsecretspec.pc" export PKG_CONFIG_PATH="$SECRETSPEC_FFI_PREFIX/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" -pkg-config --print-errors --exists secretspec_ffi -export SECRETSPEC_FFI_STATICLIB="$SECRETSPEC_FFI_PREFIX/lib/libsecretspec_ffi.a" +pkg-config --print-errors --exists libsecretspec +export SECRETSPEC_FFI_STATICLIB="$SECRETSPEC_FFI_PREFIX/lib/libsecretspec.a" export SECRETSPEC_FFI_INCLUDE="$SECRETSPEC_FFI_PREFIX/include" echo "==> SECRETSPEC_FFI_LIB=$SECRETSPEC_FFI_LIB" echo "==> SECRETSPEC_FFI_PREFIX=$SECRETSPEC_FFI_PREFIX" @@ -113,7 +113,7 @@ run_go() { ( cd secretspec-go && SECRETSPEC_FFI_PROFILE=debug bash scripts/stage-staticlib.sh ) ( cd secretspec-go && CGO_ENABLED=1 go test -tags static ./... ) - echo "==> Go (-tags pkgconfig: link inputs from secretspec_ffi.pc)" + echo "==> Go (-tags pkgconfig: link inputs from libsecretspec.pc)" ( cd secretspec-go && CGO_ENABLED=1 go test -tags pkgconfig ./... ) } @@ -126,7 +126,7 @@ run_ruby() { ( cd secretspec-rb && find examples -name '*.rb' -exec ruby -c {} \; ) echo "==> Ruby (pkg-config discovery)" - # The same link inputs read from secretspec_ffi.pc in the installed prefix + # The same link inputs read from libsecretspec.pc in the installed prefix # (PKG_CONFIG_PATH above); rebuild the extension and rerun the resolver plus # conformance contract. Codegen and cleanup behavior are independent of link # discovery and already ran above; repeating codegen would reinstall its @@ -148,10 +148,10 @@ run_node() { run_haskell() { echo "==> Haskell" - # The Haskell SDK statically links the secretspec-ffi archive at build time: the + # The Haskell SDK statically links the libsecretspec archive at build time: the # Rust resolver is embedded in the test binary, so there is NO runtime loader path - # (no LD_LIBRARY_PATH). Stage libsecretspec_ffi.a alone into an isolated dir so - # -lsecretspec_ffi resolves to the archive (target/debug also holds the .so), and + # (no LD_LIBRARY_PATH). Stage libsecretspec.a alone into an isolated dir so + # -lsecretspec resolves to the archive (target/debug also holds the .so), and # pass the archive's transitive native deps as linker options. ( cd secretspec-hs diff --git a/scripts/install-yyjson.sh b/scripts/install-yyjson.sh new file mode 100755 index 000000000..fd2602476 --- /dev/null +++ b/scripts/install-yyjson.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Install a pinned yyjson for the CI runners that build libsecretspec-resolver +# without Nix. The devenv shell gets yyjson from nixpkgs instead. +# +# install-yyjson.sh [prefix] +# +# Distribution packaging cannot cover all three runners: Ubuntu 24.04 has no +# libyyjson-dev at all, and Homebrew and vcpkg track the version independently. +# Building the pinned tag keeps every platform on the same yyjson the C client +# is tested against. +# +# Writes the discovery variables the build systems read to $GITHUB_ENV when it +# is set: PKG_CONFIG_PATH and CMAKE_PREFIX_PATH for Meson, CMake, and the +# pkg-config probe in the conformance build script, plus YYJSON_INCLUDE_DIR and +# YYJSON_LIB_DIR for runners with no pkg-config (Windows). +set -euo pipefail + +# Keep the version in sync with libsecretspec-resolver/README.md. When bumping +# it, take the digest from the release tarball itself: +# curl -fsSL https://github.com/ibireme/yyjson/archive/refs/tags/.tar.gz \ +# | sha256sum +yyjson_version="0.12.0" +yyjson_sha256="b16246f617b2a136c78d73e5e2647c6f1de1313e46678062985bdcf1f40bb75d" + +prefix="${1:-${RUNNER_TEMP:-/tmp}/yyjson}" +# GitHub's Windows runners hand out backslash paths that CMake cannot consume +# through a bash string. +if command -v cygpath >/dev/null 2>&1; then + prefix="$(cygpath -m "$prefix")" +fi + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +archive="$workdir/yyjson.tar.gz" +curl --fail --location --silent --show-error \ + "https://github.com/ibireme/yyjson/archive/refs/tags/${yyjson_version}.tar.gz" \ + --output "$archive" + +if command -v sha256sum >/dev/null 2>&1; then + observed="$(sha256sum "$archive" | cut -d' ' -f1)" +else + observed="$(shasum -a 256 "$archive" | cut -d' ' -f1)" +fi +if [[ "$observed" != "$yyjson_sha256" ]]; then + echo "yyjson ${yyjson_version} digest mismatch" >&2 + echo " expected $yyjson_sha256" >&2 + echo " observed $observed" >&2 + exit 1 +fi + +tar -xzf "$archive" -C "$workdir" + +cmake -S "$workdir/yyjson-${yyjson_version}" -B "$workdir/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DCMAKE_INSTALL_PREFIX="$prefix" +cmake --build "$workdir/build" --config Release +cmake --install "$workdir/build" --config Release + +# GNUInstallDirs picks lib or lib64 per distribution, so read the libdir back +# from the install tree rather than assuming. MSVC puts its import library +# there too, so one pair of variables covers every runner. +libdir="$(dirname "$(find "$prefix" -name 'yyjson.pc' -print -quit)")" +libdir="$(dirname "$libdir")" + +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "PKG_CONFIG_PATH=${libdir}/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" + echo "CMAKE_PREFIX_PATH=${prefix}${CMAKE_PREFIX_PATH:+:${CMAKE_PREFIX_PATH}}" + echo "YYJSON_INCLUDE_DIR=${prefix}/include" + echo "YYJSON_LIB_DIR=${libdir}" + } >>"$GITHUB_ENV" +fi + +echo "installed yyjson ${yyjson_version} to ${prefix}" diff --git a/secretspec-dotnet/README.md b/secretspec-dotnet/README.md index f1741cdc4..a1f60f71d 100644 --- a/secretspec-dotnet/README.md +++ b/secretspec-dotnet/README.md @@ -10,6 +10,10 @@ 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. +> The embedded ABI is named `libsecretspec` in SecretSpec 0.20+. It was named +> `secretspec-ffi` through 0.19; the 0.20+ native loader accepts both shared +> library filename families. + ```bash dotnet add package Cachix.SecretSpec ``` @@ -89,5 +93,5 @@ dotnet publish -c Release -r linux-x64 --self-contained \ ``` During local SDK development, `SECRETSPEC_FFI_LIB` can point to an explicit -`libsecretspec_ffi` build; the SDK also discovers a Cargo `target` directory +`libsecretspec` build; the SDK also discovers a Cargo `target` directory when used from a SecretSpec source checkout. diff --git a/secretspec-dotnet/src/SecretSpec/Native.cs b/secretspec-dotnet/src/SecretSpec/Native.cs index 8ce9ec971..24a221212 100644 --- a/secretspec-dotnet/src/SecretSpec/Native.cs +++ b/secretspec-dotnet/src/SecretSpec/Native.cs @@ -6,7 +6,7 @@ namespace Cachix.SecretSpec; internal static partial class Native { - private const string LibraryName = "secretspec_ffi"; + private const string LibraryName = "secretspec"; static Native() { @@ -67,14 +67,17 @@ private static IntPtr ResolveLibrary( // normal loader search path); the source-checkout scan below is a // development fallback that must not stat ancestor directories, or shadow // the packaged asset, in a deployed application. - if (NativeLibrary.TryLoad(libraryName, assembly, searchPath, out var packaged)) - return packaged; + foreach (var candidateName in new[] { "libsecretspec", libraryName, "secretspec_ffi" }) + { + if (NativeLibrary.TryLoad(candidateName, assembly, searchPath, out var packaged)) + return packaged; + } - var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "secretspec_ffi.dll" + var fileNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "libsecretspec.dll", "secretspec.dll", "secretspec_ffi.dll" } : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "libsecretspec_ffi.dylib" - : "libsecretspec_ffi.so"; + ? new[] { "libsecretspec.dylib", "libsecretspec_ffi.dylib" } + : new[] { "libsecretspec.so", "libsecretspec_ffi.so" }; foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { @@ -88,12 +91,15 @@ private static IntPtr ResolveLibrary( var newestTime = DateTime.MinValue; foreach (var profile in new[] { "release", "debug" }) { - var candidate = new FileInfo( - Path.Combine(directory.FullName, "target", profile, fileName)); - if (candidate.Exists && candidate.LastWriteTimeUtc >= newestTime) + foreach (var fileName in fileNames) { - newest = candidate.FullName; - newestTime = candidate.LastWriteTimeUtc; + var candidate = new FileInfo( + Path.Combine(directory.FullName, "target", profile, fileName)); + if (candidate.Exists && candidate.LastWriteTimeUtc >= newestTime) + { + newest = candidate.FullName; + newestTime = candidate.LastWriteTimeUtc; + } } } if (newest is not null) diff --git a/secretspec-dotnet/src/SecretSpec/SecretSpecBuilder.cs b/secretspec-dotnet/src/SecretSpec/SecretSpecBuilder.cs index 0843671b2..e6445b42b 100644 --- a/secretspec-dotnet/src/SecretSpec/SecretSpecBuilder.cs +++ b/secretspec-dotnet/src/SecretSpec/SecretSpecBuilder.cs @@ -135,7 +135,7 @@ private static void EnsureSchemaVersion(int actual, int expected, string kind) throw new SecretSpecException( "version", $"unsupported {kind} schema version {actual} (expected {expected}); " + - "the secretspec-ffi library and this SDK are out of sync"); + "the libsecretspec library and this SDK are out of sync"); } } } diff --git a/secretspec-go/README.md b/secretspec-go/README.md index 6688e1c1f..f70b189ab 100644 --- a/secretspec-go/README.md +++ b/secretspec-go/README.md @@ -1,13 +1,17 @@ # secretspec (Go SDK) Go bindings for [SecretSpec](https://secretspec.dev/), a declarative secrets -manager. A thin client over the `secretspec-ffi` C ABI. Resolution happens in the +manager. A thin client over the `libsecretspec` C ABI. Resolution happens in the Rust core, so the SDK inherits every provider with no Go-side logic. By default the resolver is loaded at runtime via [purego](https://github.com/ebitengine/purego) (dlopen, no cgo), keeping `go get` toolchain-free. Use `-tags static` to stage and embed the archive, or `-tags pkgconfig` (0.19+) to link an installed library (see below). +> The embedded ABI is named `libsecretspec` in SecretSpec 0.20+. It was named +> `secretspec-ffi` through 0.19; the 0.20+ runtime loader accepts both shared +> library filename families. + ```go package main @@ -71,7 +75,7 @@ for _, s := range report.Secrets { ### Default: purego (dlopen, no cgo) -The `secretspec-ffi` cdylib is resolved at runtime, in order: +The `libsecretspec` cdylib is resolved at runtime, in order: 1. The `SECRETSPEC_FFI_LIB` environment variable (an explicit path). 2. A library embedded at build time with `-tags embed_lib`. @@ -89,7 +93,7 @@ does not carry binary assets); they are attached to GitHub releases. For a self-contained binary with no runtime library to locate, link the resolver statically. This uses **cgo** (a C toolchain is required) and links -`libsecretspec_ffi.a` directly into the Go binary: +`libsecretspec.a` directly into the Go binary: ```bash # Stage the archive + header + generated cgo LDFLAGS, then build with cgo. @@ -117,7 +121,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same build command for either type: diff --git a/secretspec-go/binding_cgo.go b/secretspec-go/binding_cgo.go index 1ee95717e..31bec5c35 100644 --- a/secretspec-go/binding_cgo.go +++ b/secretspec-go/binding_cgo.go @@ -4,7 +4,7 @@ package secretspec // Linked binding: cgo links the Rust resolver at build time. The link inputs // come from files staged by scripts/stage-staticlib.sh (`-tags static`) or from -// secretspec_ffi.pc (`-tags pkgconfig`). The installed library selected by the +// libsecretspec.pc (`-tags pkgconfig`). The installed library selected by the // latter may be static or shared. /* diff --git a/secretspec-go/binding_purego.go b/secretspec-go/binding_purego.go index e7413ed25..5300655d7 100644 --- a/secretspec-go/binding_purego.go +++ b/secretspec-go/binding_purego.go @@ -29,11 +29,11 @@ var ( func libNames() []string { switch runtime.GOOS { case "darwin": - return []string{"libsecretspec_ffi.dylib"} + return []string{"libsecretspec.dylib", "libsecretspec_ffi.dylib"} case "windows": - return []string{"secretspec_ffi.dll"} + return []string{"libsecretspec.dll", "secretspec.dll", "secretspec_ffi.dll"} default: - return []string{"libsecretspec_ffi.so"} + return []string{"libsecretspec.so", "libsecretspec_ffi.so"} } } @@ -77,7 +77,7 @@ func findLibrary() (string, error) { } return "", &Error{ Kind: "load", - Message: "could not locate the secretspec-ffi library; set SECRETSPEC_FFI_LIB", + Message: "could not locate the libsecretspec library; set SECRETSPEC_FFI_LIB", } } @@ -92,7 +92,7 @@ func ensureLoaded() error { if r := recover(); r != nil { loadErr = &Error{ Kind: "load", - Message: fmt.Sprintf("failed to bind secretspec-ffi symbols (incompatible library?): %v", r), + Message: fmt.Sprintf("failed to bind libsecretspec symbols (incompatible library?): %v", r), } } }() diff --git a/secretspec-go/binding_purego_test.go b/secretspec-go/binding_purego_test.go new file mode 100644 index 000000000..417bedbe6 --- /dev/null +++ b/secretspec-go/binding_purego_test.go @@ -0,0 +1,30 @@ +//go:build !static && !pkgconfig + +package secretspec + +import ( + "runtime" + "testing" +) + +func TestLibraryNamesPreferLibsecretspecAndRetainPre020Fallback(t *testing.T) { + names := libNames() + want := []string{"libsecretspec.so", "libsecretspec_ffi.so"} + switch runtime.GOOS { + case "darwin": + want = []string{"libsecretspec.dylib", "libsecretspec_ffi.dylib"} + case "windows": + // Windows carries a third name: cargo emits secretspec.dll into + // target/, while the shipped artifact is libsecretspec.dll, so a + // developer running against a build directory still resolves. + want = []string{"libsecretspec.dll", "secretspec.dll", "secretspec_ffi.dll"} + } + if len(names) != len(want) { + t.Fatalf("library names = %v, want %v", names, want) + } + for i, name := range want { + if names[i] != name { + t.Fatalf("library names = %v, want %v", names, want) + } + } +} diff --git a/secretspec-go/cgo_pkgconfig.go b/secretspec-go/cgo_pkgconfig.go index 846f769ae..de4ded95c 100644 --- a/secretspec-go/cgo_pkgconfig.go +++ b/secretspec-go/cgo_pkgconfig.go @@ -2,10 +2,10 @@ package secretspec -// Every link input comes from an installed secretspec_ffi.pc. The install may +// Every link input comes from an installed libsecretspec.pc. The install may // contain either the static or shared library. /* -#cgo pkg-config: secretspec_ffi +#cgo pkg-config: libsecretspec */ import "C" diff --git a/secretspec-go/embedded.go b/secretspec-go/embedded.go index 4b336fcd5..38aab7973 100644 --- a/secretspec-go/embedded.go +++ b/secretspec-go/embedded.go @@ -43,7 +43,7 @@ func extractEmbedded() (string, error) { return "", err } // Content-addressed by the full digest: a different library never collides. - dir := filepath.Join(base, "secretspec-ffi", hex.EncodeToString(sum[:])) + dir := filepath.Join(base, "libsecretspec", hex.EncodeToString(sum[:])) if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } @@ -53,7 +53,7 @@ func extractEmbedded() (string, error) { // The leaf alone is not enough in the world-writable temp fallback: an // attacker can pre-create the euid-scoped base as their own 0777 dir, and // MkdirAll then nests our 0700 leaf inside it — the leaf check passes, yet the - // attacker owns an ancestor and can rename `secretspec-ffi/` between extraction + // attacker owns an ancestor and can rename `libsecretspec/` between extraction // and dlopen to swap the library. Verify the first directory this code is // responsible for; once it is confirmed ours and 0700, no other user can reach // the tree below it. The primary cache base ($HOME/.cache) is created by the OS diff --git a/secretspec-go/embedded_darwin_arm64.go b/secretspec-go/embedded_darwin_arm64.go index f9a93406d..d7795a0cc 100644 --- a/secretspec-go/embedded_darwin_arm64.go +++ b/secretspec-go/embedded_darwin_arm64.go @@ -4,7 +4,7 @@ package secretspec import _ "embed" -//go:embed lib/secretspec_ffi_darwin_arm64.dylib +//go:embed lib/secretspec_darwin_arm64.dylib var embeddedLib []byte -const embeddedLibName = "libsecretspec_ffi.dylib" +const embeddedLibName = "libsecretspec.dylib" diff --git a/secretspec-go/embedded_linux_amd64.go b/secretspec-go/embedded_linux_amd64.go index ed8fea1e7..908009579 100644 --- a/secretspec-go/embedded_linux_amd64.go +++ b/secretspec-go/embedded_linux_amd64.go @@ -4,7 +4,7 @@ package secretspec import _ "embed" -//go:embed lib/secretspec_ffi_linux_amd64.so +//go:embed lib/secretspec_linux_amd64.so var embeddedLib []byte -const embeddedLibName = "libsecretspec_ffi.so" +const embeddedLibName = "libsecretspec.so" diff --git a/secretspec-go/embedded_linux_arm64.go b/secretspec-go/embedded_linux_arm64.go index 9af474e9d..71269dc68 100644 --- a/secretspec-go/embedded_linux_arm64.go +++ b/secretspec-go/embedded_linux_arm64.go @@ -4,7 +4,7 @@ package secretspec import _ "embed" -//go:embed lib/secretspec_ffi_linux_arm64.so +//go:embed lib/secretspec_linux_arm64.so var embeddedLib []byte -const embeddedLibName = "libsecretspec_ffi.so" +const embeddedLibName = "libsecretspec.so" diff --git a/secretspec-go/embedded_windows_amd64.go b/secretspec-go/embedded_windows_amd64.go index c11912d1e..24b12be9b 100644 --- a/secretspec-go/embedded_windows_amd64.go +++ b/secretspec-go/embedded_windows_amd64.go @@ -4,7 +4,7 @@ package secretspec import _ "embed" -//go:embed lib/secretspec_ffi_windows_amd64.dll +//go:embed lib/secretspec_windows_amd64.dll var embeddedLib []byte -const embeddedLibName = "secretspec_ffi.dll" +const embeddedLibName = "libsecretspec.dll" diff --git a/secretspec-go/scripts/build-static-musl.sh b/secretspec-go/scripts/build-static-musl.sh index 87639b96e..476717e95 100644 --- a/secretspec-go/scripts/build-static-musl.sh +++ b/secretspec-go/scripts/build-static-musl.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Build a fully-static (musl) Go binary that links the secretspec-ffi archive in +# Build a fully-static (musl) Go binary that links the libsecretspec archive in # via cgo. Run inside the project devenv shell, which provides the musl C # cross-toolchain and static libunwind via MUSL_CC / MUSL_STATIC_LDFLAGS # (and the CC_/linker env so cargo compiles the C deps against musl): diff --git a/secretspec-go/scripts/stage-cdylib.sh b/secretspec-go/scripts/stage-cdylib.sh index fcb1ff0f3..d74ee65ae 100755 --- a/secretspec-go/scripts/stage-cdylib.sh +++ b/secretspec-go/scripts/stage-cdylib.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Build the secretspec-ffi cdylib (release) and stage it into lib/ under the +# Build the libsecretspec cdylib (release) and stage it into lib/ under the # build-tagged name the embedded__.go files reference, so `go build` # embeds it. Run before building/releasing the Go module. # @@ -18,7 +18,7 @@ goos="$(go env GOOS)" goarch="$(go env GOARCH)" build_args=( - -p secretspec-ffi + -p libsecretspec --release --manifest-path "$repo_root/Cargo.toml" ) @@ -28,11 +28,11 @@ target_dir="$(cargo metadata --no-deps --format-version 1 --manifest-path "$repo | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" case "$goos" in - darwin) src="libsecretspec_ffi.dylib"; ext="dylib" ;; - windows) src="secretspec_ffi.dll"; ext="dll" ;; - *) src="libsecretspec_ffi.so"; ext="so" ;; + darwin) src="libsecretspec.dylib"; ext="dylib" ;; + windows) src="secretspec.dll"; ext="dll" ;; + *) src="libsecretspec.so"; ext="so" ;; esac mkdir -p "$pkg_dir/lib" -cp "$target_dir/release/$src" "$pkg_dir/lib/secretspec_ffi_${goos}_${goarch}.${ext}" -echo "staged secretspec_ffi_${goos}_${goarch}.${ext} into lib/" +cp "$target_dir/release/$src" "$pkg_dir/lib/secretspec_${goos}_${goarch}.${ext}" +echo "staged secretspec_${goos}_${goarch}.${ext} into lib/" diff --git a/secretspec-go/scripts/stage-staticlib.sh b/secretspec-go/scripts/stage-staticlib.sh index d3dede8e9..c657ca008 100644 --- a/secretspec-go/scripts/stage-staticlib.sh +++ b/secretspec-go/scripts/stage-staticlib.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # -# Stage the secretspec-ffi staticlib for the `-tags static` cgo build: the +# Stage the libsecretspec staticlib for the `-tags static` cgo build: the # per-platform archive (lib/), the C header (include/), and a generated # cgo_ldflags__.go carrying the archive path + its transitive native # deps (captured from `rustc --print native-static-libs`, never hardcoded). # A `-tags pkgconfig` build skips the staged inputs and reads an installed -# secretspec_ffi.pc instead. +# libsecretspec.pc instead. # # Honors: # SECRETSPEC_FFI_PROFILE release|debug (default: debug) @@ -21,7 +21,7 @@ goarch="$(go env GOARCH)" profile="${SECRETSPEC_FFI_PROFILE:-debug}" target="${SECRETSPEC_FFI_TARGET:-}" -build=(-p secretspec-ffi --manifest-path "$repo_root/Cargo.toml") +build=(-p libsecretspec --manifest-path "$repo_root/Cargo.toml") [ "$profile" = release ] && build+=(--release) [ -n "$target" ] && build+=(--target "$target") cargo build "${build[@]}" @@ -31,11 +31,11 @@ native_libs="$(cargo rustc -q "${build[@]}" --crate-type staticlib -- \ tdir="$(cargo metadata --no-deps --format-version 1 --manifest-path "$repo_root/Cargo.toml" \ | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" -a_path="$tdir/${target:+$target/}$profile/libsecretspec_ffi.a" +a_path="$tdir/${target:+$target/}$profile/libsecretspec.a" mkdir -p "$pkg_dir/lib" "$pkg_dir/include" -cp "$a_path" "$pkg_dir/lib/libsecretspec_ffi_${goos}_${goarch}.a" -cp "$repo_root/secretspec-ffi/include/secretspec.h" "$pkg_dir/include/secretspec.h" +cp "$a_path" "$pkg_dir/lib/libsecretspec_${goos}_${goarch}.a" +cp "$repo_root/libsecretspec/include/secretspec.h" "$pkg_dir/include/secretspec.h" # The cgo LDFLAGS live in a generated per-platform file (the wasmtime-go pattern): # the archive is pulled for the referenced symbols, then its native deps follow. @@ -45,9 +45,9 @@ cat > "$pkg_dir/cgo_ldflags_${goos}_${goarch}.go" < The embedded ABI and its pkg-config file are named `libsecretspec` in +> SecretSpec 0.20+. Through 0.19 they were named `secretspec-ffi` and +> `secretspec_ffi.pc`. + ```haskell import qualified SecretSpec as S import qualified Data.Map.Strict as Map @@ -68,18 +72,18 @@ secretspec schema | quicktype -s schema --top-level SecretSpec --lang haskell -o ## Building -The build links the `secretspec-ffi` archive statically. Stage the `.a` in a +The build links the `libsecretspec` archive statically. Stage the `.a` in a directory of its own (so the linker picks the archive, not the co-located `.so`) and pass its native dependencies to the linker: ```bash -cargo build -p secretspec-ffi +cargo build -p libsecretspec TARGET="$(cargo metadata --no-deps --format-version 1 \ | grep -o '"target_directory":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')" LIBDIR="$(mktemp -d)" -cp "$TARGET/debug/libsecretspec_ffi.a" "$LIBDIR/" -NATIVE_LIBS="$(cargo rustc -q -p secretspec-ffi --crate-type staticlib -- \ +cp "$TARGET/debug/libsecretspec.a" "$LIBDIR/" +NATIVE_LIBS="$(cargo rustc -q -p libsecretspec --crate-type staticlib -- \ --print native-static-libs 2>&1 | sed -n 's/^note: native-static-libs: //p' | tail -1)" cabal build --extra-lib-dirs="$LIBDIR" --ghc-options="-optl${NATIVE_LIBS// / -optl}" @@ -92,7 +96,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same Cabal flag for either type: diff --git a/secretspec-hs/secretspec.cabal b/secretspec-hs/secretspec.cabal index 73ac2849d..0aa3e0486 100644 --- a/secretspec-hs/secretspec.cabal +++ b/secretspec-hs/secretspec.cabal @@ -3,7 +3,7 @@ name: secretspec version: 0.19.1 synopsis: Haskell SDK for SecretSpec, a declarative secrets manager description: - A thin client over the @secretspec-ffi@ C ABI (linked at build time). + A thin client over the @libsecretspec@ C ABI (linked at build time). Resolution (providers, chains, profiles, generation, @as_path@) happens in the Rust core; this package marshals a JSON request to the native library and parses the response, mirroring the Rust derive crate's vocabulary. @@ -17,8 +17,8 @@ build-type: Simple flag use-pkg-config description: - Locate secretspec_ffi and its link dependencies through pkg-config - (secretspec_ffi.pc) instead of command-line library and linker paths. + Locate secretspec and its link dependencies through pkg-config + (libsecretspec.pc) instead of command-line library and linker paths. default: False manual: True @@ -35,14 +35,14 @@ library -- the static archive; pkg-config may select a static or shared install. -- -- Without the flag: point --extra-lib-dirs at a directory containing ONLY - -- libsecretspec_ffi.a (so -lsecretspec_ffi resolves to the archive, not a + -- libsecretspec.a (so -lsecretspec resolves to the archive, not a -- co-located .so), and pass the archive's transitive native deps via -- --ghc-options=-optl (capture them with - -- `cargo rustc -p secretspec-ffi --crate-type staticlib -- --print native-static-libs`). + -- `cargo rustc -p libsecretspec --crate-type staticlib -- --print native-static-libs`). if flag(use-pkg-config) - pkgconfig-depends: secretspec_ffi + pkgconfig-depends: libsecretspec else - extra-libraries: secretspec_ffi + extra-libraries: secretspec -- The Darwin frameworks among the archive's native dependencies, declared -- so every final link (executable, test-suite, ghci) receives them through -- the compiler driver. diff --git a/secretspec-hs/src/SecretSpec.hs b/secretspec-hs/src/SecretSpec.hs index d0647a9d2..d9d05dfe3 100644 --- a/secretspec-hs/src/SecretSpec.hs +++ b/secretspec-hs/src/SecretSpec.hs @@ -3,7 +3,7 @@ -- | Haskell SDK for SecretSpec, a declarative secrets manager. -- --- A thin client over the @secretspec-ffi@ C ABI, linked at build time. +-- A thin client over the @libsecretspec@ C ABI, linked at build time. -- Resolution (providers, fallback chains, profiles, generation, @as_path@) -- happens entirely in the Rust core; this module marshals a JSON request to -- @secretspec_resolve@, parses the response envelope, and exposes it with the @@ -371,7 +371,7 @@ versionError got expected kind = T.concat [ "unsupported ", kind, " schema version ", T.pack (show got) , " (expected ", T.pack (show expected) - , "); the secretspec-ffi library and this SDK are out of sync" + , "); the libsecretspec library and this SDK are out of sync" ] fromResult :: Either String a -> IO a diff --git a/secretspec-ipc/Cargo.toml b/secretspec-ipc/Cargo.toml new file mode 100644 index 000000000..e7eb12d51 --- /dev/null +++ b/secretspec-ipc/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "secretspec-ipc" +version.workspace = true +edition.workspace = true +repository = "https://github.com/cachix/secretspec" +description = "Independent Rust implementation of the SecretSpec IPC protocols" +license = "Apache-2.0" + +[features] +default = ["tokio"] +# Synchronous `secretspec.resolver/1` sessions over `std::process`, for consumers +# that have no async runtime and must not acquire one. Adds no dependencies. +blocking = [] +tokio = [ + "dep:async-trait", + "dep:tokio", + "dep:tokio-util", +] + +[dependencies] +async-trait = { workspace = true, optional = true } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +# `Zeroizing` only. Declared here rather than inherited from the workspace so +# the unused `derive` feature, and the proc-macro crate behind it, stay out of +# the dependency tree of a consumer that depends on this crate alone. +zeroize = { version = "1.8", default-features = false, features = ["alloc"] } +tokio = { workspace = true, optional = true, features = ["io-util", "macros", "process", "sync", "time"] } +tokio-util = { workspace = true, optional = true } + +[dev-dependencies] +jsonschema = { version = "0.48.5", default-features = false } +proptest.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["io-util", "macros", "process", "sync", "time"] } diff --git a/secretspec-ipc/README.md b/secretspec-ipc/README.md new file mode 100644 index 000000000..e357c6b51 --- /dev/null +++ b/secretspec-ipc/README.md @@ -0,0 +1,30 @@ +# secretspec-ipc + +Independent Rust implementation of SecretSpec IPC version 1 (SecretSpec +0.20+). The checked-in JSON Schema and OpenRPC documents under +`schema/ipc/v1/` are canonical; this crate supplies strict framing/envelopes, +multiplexed clients, a server dispatcher, child lifecycle management, and typed +resolution/provider handler APIs. + +Endpoints also answer `rpc.discover` before or after initialization (SecretSpec +0.20+). Discovery returns a self-contained OpenRPC document with its JSON +Schemas and endpoint metadata without loading application or provider state. +The crate packages the canonical discovery assets under `schema/ipc/v1/`. + +The runtime-independent codec builds without Tokio: + +```console +cargo check -p secretspec-ipc --no-default-features +``` + +The default `tokio` feature enables async transports, clients, servers, process +launch, and handler adapters. + +The `blocking` feature adds a synchronous `secretspec.resolver/1` session over +`std::process`, for a consumer that has no async runtime and should not acquire +one. It reuses the same framing, envelopes, and validation, and adds no +dependency beyond the runtime-independent set: + +```console +cargo check -p secretspec-ipc --no-default-features --features blocking +``` diff --git a/secretspec-ipc/schema/ipc/v1/common.schema.json b/secretspec-ipc/schema/ipc/v1/common.schema.json new file mode 100644 index 000000000..d10ec6470 --- /dev/null +++ b/secretspec-ipc/schema/ipc/v1/common.schema.json @@ -0,0 +1,294 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "title": "SecretSpec IPC version 1 common types", + "$defs": { + "RequestId": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "DeadlineUnixMs": { + "type": "integer", + "minimum": 0, + "maximum": 18446744073709551615 + }, + "Methods": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "Capabilities": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "DiscoveryDocument": { + "description": "A self-contained OpenRPC document. The x-secretspec extension reports endpoint-specific discovery metadata.", + "type": "object", + "required": ["openrpc", "info", "methods", "components", "x-secretspec"], + "properties": { + "openrpc": { "type": "string", "minLength": 1 }, + "info": { "type": "object" }, + "methods": { "type": "array" }, + "components": { "type": "object" }, + "x-secretspec": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "server", "methods", "absolute_max_frame_bytes"], + "properties": { + "protocol": { + "type": "string", + "enum": ["secretspec.resolver", "secretspec.provider"] + }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1 } + }, + "server": { "$ref": "#/$defs/Product" }, + "methods": { "$ref": "#/$defs/Methods" }, + "absolute_max_frame_bytes": { + "type": "integer", + "const": 1048576 + } + } + } + } + }, + "Meta": { + "type": "object", + "additionalProperties": false, + "required": ["deadline_unix_ms"], + "properties": { + "deadline_unix_ms": { "$ref": "#/$defs/DeadlineUnixMs" }, + "parent_request_id": { "$ref": "#/$defs/RequestId" } + } + }, + "Product": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "version": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "Limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_frame_bytes", "max_in_flight"], + "properties": { + "max_frame_bytes": { + "type": "integer", + "minimum": 4096, + "maximum": 1048576 + }, + "max_in_flight": { + "type": "integer", + "minimum": 1, + "maximum": 32 + } + } + }, + "ErrorKind": { + "description": "Kinds a conforming version 1 sender emits. Closed for senders, open for receivers: a receiver decodes a value outside this set as an unnamed failure rather than rejecting the frame, so a later revision can add one without a new protocol version.", + "type": "string", + "enum": [ + "parse_error", + "invalid_request", + "method_not_found", + "invalid_params", + "internal", + "unsupported_version", + "capability_required", + "deadline_exceeded", + "cancelled", + "unavailable", + "permission_denied", + "interaction_required", + "conflict", + "operation_failed", + "message_too_large", + "representation_mismatch" + ] + }, + "ErrorData": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "retryable"], + "properties": { + "kind": { "$ref": "#/$defs/ErrorKind" }, + "retryable": { "type": "boolean" }, + "retry_after_ms": { + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + }, + "interaction": { "$ref": "#/$defs/InteractionReference" } + }, + "allOf": [ + { + "if": { "required": ["interaction"] }, + "then": { "properties": { "kind": { "const": "interaction_required" } } } + } + ] + }, + "InteractionReference": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "expires_at_unix_ms"], + "properties": { + "kind": { "type": "string", "enum": ["authorization"] }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "expires_at_unix_ms": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 18446744073709551615 + } + } + }, + "RpcError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "data"], + "properties": { + "code": { + "type": "integer", + "enum": [ + -32700, + -32600, + -32601, + -32602, + -32603, + -32000, + -32001, + -32002, + -32003, + -32004, + -32005, + -32006, + -32007, + -32008, + -32009, + -32010 + ] + }, + "message": { "type": "string", "minLength": 1, "maxLength": 256 }, + "data": { "$ref": "#/$defs/ErrorData" } + } + }, + "RequestEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "method", "_meta", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "method": { "type": "string", "minLength": 1, "maxLength": 256 }, + "_meta": { "$ref": "#/$defs/Meta" }, + "params": { "type": "object" } + } + }, + "NotificationEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { "type": "string", "minLength": 1, "maxLength": 256 }, + "params": { "type": "object" } + } + }, + "SuccessResponseEnvelope": { + "type": "object", + "additionalProperties": true, + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "result": {} + } + }, + "ErrorResponseEnvelope": { + "type": "object", + "additionalProperties": true, + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { + "oneOf": [ + { "$ref": "#/$defs/RequestId" }, + { "type": "null" } + ] + }, + "error": { "$ref": "#/$defs/RpcError" } + } + }, + "CancelParams": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/RequestId" } + } + }, + "EmptyParams": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "EmptyResult": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "Persistence": { + "type": "string", + "enum": ["persist", "ephemeral"] + }, + "Coordinates": { + "type": "object", + "additionalProperties": false, + "required": ["item"], + "properties": { + "item": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "field": { "type": ["string", "null"], "maxLength": 4096 }, + "vault": { "type": ["string", "null"], "maxLength": 4096 }, + "section": { "type": ["string", "null"], "maxLength": 4096 }, + "version": { "type": ["string", "null"], "maxLength": 4096 } + } + }, + "ConventionAddress": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "project", "profile", "key"], + "properties": { + "kind": { "const": "convention" }, + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 }, + "key": { "type": "string", "maxLength": 4096 } + } + }, + "NativeAddress": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "coordinates"], + "properties": { + "kind": { "const": "native" }, + "coordinates": { "$ref": "#/$defs/Coordinates" } + } + }, + "Address": { + "oneOf": [ + { "$ref": "#/$defs/ConventionAddress" }, + { "$ref": "#/$defs/NativeAddress" } + ] + } + } +} diff --git a/secretspec-ipc/schema/ipc/v1/provider.openrpc.json b/secretspec-ipc/schema/ipc/v1/provider.openrpc.json new file mode 100644 index 000000000..4ac30183e --- /dev/null +++ b/secretspec-ipc/schema/ipc/v1/provider.openrpc.json @@ -0,0 +1,96 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "SecretSpec Secret Provider Protocol", + "version": "1" + }, + "methods": [ + { + "name": "rpc.discover", + "summary": "Return this endpoint's self-contained OpenRPC description without initializing application state (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "OpenRPC document", "schema": { "$ref": "common.schema.json#/$defs/DiscoveryDocument" } } + }, + { + "name": "rpc.initialize", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/InitializeParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/InitializeResult" } } + }, + { + "name": "client.credential", + "summary": "Provider callback requesting one namespaced authentication credential", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/CredentialParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/CredentialResult" } } + }, + { + "name": "provider.resolve_address", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ResolveAddressResult" } } + }, + { + "name": "provider.get", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/GetResult" } } + }, + { + "name": "provider.get_many", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/GetManyParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/GetManyResult" } } + }, + { + "name": "provider.exists", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ExistsResult" } } + }, + { + "name": "provider.set", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/SetParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/StoredResult" } } + }, + { + "name": "provider.set_expiring", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/SetExpiringParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/StoredResult" } } + }, + { + "name": "provider.delete", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/DeletedResult" } } + }, + { + "name": "provider.clear", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/ClearParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ClearResult" } } + }, + { + "name": "provider.check_writable", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + }, + { + "name": "provider.check_deletable", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + }, + { + "name": "provider.describe_write_target", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/AddressParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/DescribeWriteTargetResult" } } + }, + { + "name": "provider.reflect", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "provider.schema.json#/$defs/ReflectParams" } }], + "result": { "name": "result", "schema": { "$ref": "provider.schema.json#/$defs/ReflectResult" } } + }, + { + "name": "rpc.cancel", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/CancelParams" } }], + "result": { "name": "notification", "schema": { "type": "null" } } + }, + { + "name": "rpc.shutdown", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + } + ] +} diff --git a/secretspec-ipc/schema/ipc/v1/provider.schema.json b/secretspec-ipc/schema/ipc/v1/provider.schema.json new file mode 100644 index 000000000..4cac698d9 --- /dev/null +++ b/secretspec-ipc/schema/ipc/v1/provider.schema.json @@ -0,0 +1,338 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/provider.schema.json", + "title": "SecretSpec Secret Provider Protocol version 1", + "$defs": { + "InitializeApplication": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "uri", "context"], + "properties": { + "scheme": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "uri": { "type": "string", "minLength": 1, "maxLength": 32768 }, + "context": { "$ref": "#/$defs/ApplicationContext" } + } + }, + "CredentialParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "scope", "required"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$", "maxLength": 256 }, + "scope": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "required": { "type": "boolean" } + } + }, + "CredentialResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "value"], + "properties": { + "status": { "const": "found" }, + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "missing" } } + } + ] + }, + "ApplicationContext": { + "type": "object", + "additionalProperties": false, + "required": ["project", "profile", "base_dir", "reason"], + "properties": { + "project": { "type": ["string", "null"], "minLength": 1, "maxLength": 4096 }, + "profile": { "type": ["string", "null"], "minLength": 1, "maxLength": 4096 }, + "base_dir": { "type": ["string", "null"], "maxLength": 32768 }, + "reason": { "type": ["string", "null"], "maxLength": 4096 }, + "requested_authorization_duration_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "ProviderMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "display_uri", + "supported_coordinates", + "generated_value_persistence", + "prompted_value_persistence", + "storage_identity", + "entry_container_identity", + "physical_store_path" + ], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "display_uri": { "type": "string", "maxLength": 32768 }, + "supported_coordinates": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "enum": ["field", "vault", "section", "version"] } + }, + "generated_value_persistence": { "$ref": "common.schema.json#/$defs/Persistence" }, + "prompted_value_persistence": { "$ref": "common.schema.json#/$defs/Persistence" }, + "storage_identity": { "type": "string", "maxLength": 32768 }, + "entry_container_identity": { "type": "string", "maxLength": 32768 }, + "physical_store_path": { "type": ["string", "null"], "maxLength": 32768 } + } + }, + "InitializedApplication": { + "type": "object", + "additionalProperties": false, + "required": ["provider"], + "properties": { + "provider": { "$ref": "#/$defs/ProviderMetadata" } + } + }, + "InitializeParams": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "client", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.provider" }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 4294967295 } + }, + "client": { "$ref": "common.schema.json#/$defs/Product" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "client_methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "application": { "$ref": "#/$defs/InitializeApplication" } + } + }, + "InitializeResult": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "version", "server", "methods", "capabilities", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.provider" }, + "version": { "const": 1 }, + "server": { "$ref": "common.schema.json#/$defs/Product" }, + "methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "capabilities": { "$ref": "common.schema.json#/$defs/Capabilities" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "application": { "$ref": "#/$defs/InitializedApplication" } + } + }, + "AddressParams": { + "type": "object", + "additionalProperties": false, + "required": ["address"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" } + } + }, + "ResolveAddressResult": { + "type": "object", + "additionalProperties": false, + "required": ["coordinates"], + "properties": { + "coordinates": { "$ref": "common.schema.json#/$defs/Coordinates" } + } + }, + "GetResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "value", "expires_at_unix_ms"], + "properties": { + "status": { "const": "found" }, + "value": { "type": "string" }, + "expires_at_unix_ms": { + "description": "Authoritative time at which the secret itself expires, or null when the provider knows no bound. This is not provider or resolver cache freshness.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "missing" } } + } + ] + }, + "GetManyParams": { + "type": "object", + "additionalProperties": false, + "required": ["requests"], + "properties": { + "requests": { + "type": "array", + "maxItems": 1024, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "address"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "address": { "$ref": "common.schema.json#/$defs/Address" } + } + } + } + } + }, + "NamedGetResult": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["name", "status", "value", "expires_at_unix_ms"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "status": { "const": "found" }, + "value": { "type": "string" }, + "expires_at_unix_ms": { + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "status"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "status": { "const": "missing" } + } + } + ] + }, + "GetManyResult": { + "type": "object", + "additionalProperties": false, + "required": ["results"], + "properties": { + "results": { + "type": "array", + "maxItems": 1024, + "items": { "$ref": "#/$defs/NamedGetResult" } + } + } + }, + "ExistsResult": { + "type": "object", + "additionalProperties": false, + "required": ["exists"], + "properties": { "exists": { "type": "boolean" } } + }, + "SetParams": { + "type": "object", + "additionalProperties": false, + "required": ["address", "value"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" }, + "value": { "type": "string" } + } + }, + "SetExpiringParams": { + "type": "object", + "additionalProperties": false, + "required": ["address", "value", "ttl_ms"], + "properties": { + "address": { "$ref": "common.schema.json#/$defs/Address" }, + "value": { "type": "string" }, + "ttl_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "StoredResult": { + "type": "object", + "additionalProperties": false, + "required": ["stored"], + "properties": { "stored": { "const": true } } + }, + "DeletedResult": { + "type": "object", + "additionalProperties": false, + "required": ["deleted"], + "properties": { "deleted": { "type": "boolean" } } + }, + "ClearScope": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { "kind": { "const": "all" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "project", "profile"], + "properties": { + "kind": { "const": "convention" }, + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 } + } + } + ] + }, + "ClearParams": { + "type": "object", + "additionalProperties": false, + "required": ["scope"], + "properties": { + "scope": { "$ref": "#/$defs/ClearScope" } + } + }, + "ClearResult": { + "type": "object", + "additionalProperties": false, + "required": ["cleared"], + "properties": { "cleared": { "type": "integer", "minimum": 0 } } + }, + "DescribeWriteTargetResult": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "properties": { "description": { "type": "string", "maxLength": 4096 } } + }, + "ReflectParams": { + "type": "object", + "additionalProperties": false, + "required": ["project", "profile"], + "properties": { + "project": { "type": "string", "maxLength": 4096 }, + "profile": { "type": "string", "maxLength": 4096 } + } + }, + "ReflectedDeclaration": { + "type": "object", + "additionalProperties": false, + "required": ["description", "required", "ref"], + "properties": { + "description": { "type": "string", "maxLength": 4096 }, + "required": { "type": "boolean" }, + "ref": { "$ref": "common.schema.json#/$defs/Coordinates" } + } + }, + "ReflectResult": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "declarations"], + "properties": { + "schema_version": { "const": 1 }, + "declarations": { + "type": "object", + "propertyNames": { "maxLength": 4096 }, + "additionalProperties": { "$ref": "#/$defs/ReflectedDeclaration" } + } + } + } + } +} diff --git a/secretspec-ipc/schema/ipc/v1/resolver.openrpc.json b/secretspec-ipc/schema/ipc/v1/resolver.openrpc.json new file mode 100644 index 000000000..2075d5b57 --- /dev/null +++ b/secretspec-ipc/schema/ipc/v1/resolver.openrpc.json @@ -0,0 +1,72 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "SecretSpec Secret Resolution Protocol", + "version": "1" + }, + "methods": [ + { + "name": "rpc.discover", + "summary": "Return this endpoint's self-contained OpenRPC description without initializing application state (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "OpenRPC document", "schema": { "$ref": "common.schema.json#/$defs/DiscoveryDocument" } } + }, + { + "name": "rpc.initialize", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/InitializeParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/InitializeResult" } } + }, + { + "name": "resolver.get", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/GetParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/GetResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.release", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/ReleaseParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/ReleaseResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.set", + "summary": "Store one declared name. Optional: advertised only by endpoints that accept writes (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/SetParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/SetResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "resolver.delete", + "summary": "Remove one declared name's stored value. Optional, like resolver.set (0.20+).", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/DeleteParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/DeleteResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "client.prompt", + "summary": "Sent by the endpoint to its client: obtain one secret value from a person (0.20+). Only when the client advertised it in client_methods.", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "resolver.schema.json#/$defs/PromptParams" } }], + "result": { "name": "result", "schema": { "$ref": "resolver.schema.json#/$defs/PromptResult" } }, + "errors": [{ "$ref": "#/components/errors/CommonError" }] + }, + { + "name": "rpc.cancel", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/CancelParams" } }], + "result": { "name": "notification", "schema": { "type": "null" } } + }, + { + "name": "rpc.shutdown", + "params": [{ "name": "params", "required": true, "schema": { "$ref": "common.schema.json#/$defs/EmptyParams" } }], + "result": { "name": "result", "schema": { "$ref": "common.schema.json#/$defs/EmptyResult" } } + } + ], + "components": { + "errors": { + "CommonError": { + "code": -32008, + "message": "operation failed", + "data": { "$ref": "common.schema.json#/$defs/ErrorData" } + } + } + } +} diff --git a/secretspec-ipc/schema/ipc/v1/resolver.schema.json b/secretspec-ipc/schema/ipc/v1/resolver.schema.json new file mode 100644 index 000000000..bea5ac266 --- /dev/null +++ b/secretspec-ipc/schema/ipc/v1/resolver.schema.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/ipc/v1/resolver.schema.json", + "title": "SecretSpec Secret Resolution Protocol version 1", + "$defs": { + "PathManifest": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path"], + "properties": { + "kind": { "const": "path" }, + "path": { "type": "string", "minLength": 1, "maxLength": 32768 } + } + }, + "InlineManifest": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "toml", "base_dir"], + "properties": { + "kind": { "const": "inline" }, + "toml": { "type": "string", "maxLength": 1048576 }, + "base_dir": { "type": "string", "minLength": 1, "maxLength": 32768 } + } + }, + "Manifest": { + "oneOf": [ + { "$ref": "#/$defs/PathManifest" }, + { "$ref": "#/$defs/InlineManifest" } + ] + }, + "InitializeApplication": { + "type": "object", + "additionalProperties": false, + "required": ["manifest", "provider", "profile", "scope", "reason"], + "properties": { + "manifest": { "$ref": "#/$defs/Manifest" }, + "provider": { "type": ["string", "null"], "maxLength": 32768 }, + "profile": { "type": ["string", "null"], "maxLength": 4096 }, + "scope": { "type": ["string", "null"], "maxLength": 4096 }, + "reason": { "type": ["string", "null"], "maxLength": 4096 }, + "requested_authorization_duration_ms": { "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + } + }, + "InitializedApplication": { + "type": "object", + "additionalProperties": false, + "required": ["manifest_kind", "supports_inline_manifest"], + "properties": { + "manifest_kind": { "type": "string", "enum": ["path", "inline"] }, + "supports_inline_manifest": { "type": "boolean" } + } + }, + "InitializeParams": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "versions", "client", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.resolver" }, + "versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 4294967295 } + }, + "client": { "$ref": "common.schema.json#/$defs/Product" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "client_methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "application": { "$ref": "#/$defs/InitializeApplication" } + } + }, + "InitializeResult": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "version", "server", "methods", "capabilities", "limits", "application"], + "properties": { + "protocol": { "const": "secretspec.resolver" }, + "version": { "const": 1 }, + "server": { "$ref": "common.schema.json#/$defs/Product" }, + "methods": { "$ref": "common.schema.json#/$defs/Methods" }, + "capabilities": { "$ref": "common.schema.json#/$defs/Capabilities" }, + "limits": { "$ref": "common.schema.json#/$defs/Limits" }, + "application": { "$ref": "#/$defs/InitializedApplication" } + } + }, + "Purpose": { + "type": "object", + "additionalProperties": false, + "required": ["consumer", "operation"], + "properties": { + "consumer": { "type": "string", "minLength": 1, "maxLength": 256 }, + "operation": { "type": "string", "minLength": 1, "maxLength": 256 }, + "host": { "type": "string", "maxLength": 4096 }, + "path": { "type": "string", "maxLength": 4096 } + } + }, + "GetParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "representation", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "representation": { "type": "string", "enum": ["auto", "value", "path"] }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "UndeclaredResult": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "undeclared" } } + }, + "MissingResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "required"], + "properties": { + "status": { "const": "missing" }, + "required": { "type": "boolean" } + } + }, + "ResolvedValueResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "representation", "value", "source", "expires_at_unix_ms", "refresh_at_unix_ms"], + "properties": { + "status": { "const": "resolved" }, + "representation": { "const": "value" }, + "value": { "type": "string" }, + "source": { "description": "Origins a conforming version 1 resolver emits. Closed for the resolver, open for the client, like ErrorKind.", "type": "string", "enum": ["provider", "generated", "default", "composed"] }, + "source_provider": { "type": "string", "maxLength": 32768 }, + "expires_at_unix_ms": { + "description": "Provider-reported time at which the secret itself expires. Null means no bound is known, not that the secret is permanent.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + }, + "refresh_at_unix_ms": { + "description": "When the resolver stops considering its cached copy current and will consult the authoritative route again. Null means this result was not served from a resolver cache.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + "ResolvedPathResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "representation", "path", "path_lease_id", "source", "expires_at_unix_ms", "refresh_at_unix_ms"], + "properties": { + "status": { "const": "resolved" }, + "representation": { "const": "path" }, + "path": { "type": "string", "minLength": 1, "maxLength": 32768 }, + "path_lease_id": { "type": "string", "minLength": 22, "maxLength": 256 }, + "source": { "description": "Origins a conforming version 1 resolver emits. Closed for the resolver, open for the client, like ErrorKind.", "type": "string", "enum": ["provider", "generated", "default", "composed"] }, + "source_provider": { "type": "string", "maxLength": 32768 }, + "expires_at_unix_ms": { + "description": "Provider-reported time at which the secret itself expires. Null means no bound is known, not that the secret is permanent.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + }, + "refresh_at_unix_ms": { + "description": "When the resolver stops considering its cached copy current and will consult the authoritative route again. Null means this result was not served from a resolver cache.", + "oneOf": [ + { "$ref": "common.schema.json#/$defs/DeadlineUnixMs" }, + { "type": "null" } + ] + } + } + }, + "GetResult": { + "oneOf": [ + { "$ref": "#/$defs/UndeclaredResult" }, + { "$ref": "#/$defs/MissingResult" }, + { "$ref": "#/$defs/ResolvedValueResult" }, + { "$ref": "#/$defs/ResolvedPathResult" } + ] + }, + "PromptParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "profile"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "profile": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "PromptResult": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 } + } + }, + "SetParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "value": { "type": "string", "minLength": 1, "maxLength": 1048576 }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "SetResult": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { "const": "stored" }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "DeleteParams": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purpose"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "purpose": { "$ref": "#/$defs/Purpose" } + } + }, + "DeleteResult": { + "type": "object", + "additionalProperties": false, + "required": ["status", "deleted"], + "properties": { + "status": { "const": "deleted" }, + "deleted": { "type": "boolean" }, + "target_provider": { "type": "string", "maxLength": 32768 } + } + }, + "ReleaseParams": { + "type": "object", + "additionalProperties": false, + "required": ["path_lease_ids"], + "properties": { + "path_lease_ids": { + "type": "array", + "maxItems": 256, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + }, + "ReleaseResult": { + "type": "object", + "additionalProperties": false, + "required": ["released"], + "properties": { + "released": { "type": "integer", "minimum": 0, "maximum": 256 } + } + } + } +} diff --git a/secretspec-ipc/src/blocking.rs b/secretspec-ipc/src/blocking.rs new file mode 100644 index 000000000..f7412929f --- /dev/null +++ b/secretspec-ipc/src/blocking.rs @@ -0,0 +1,634 @@ +//! Synchronous `secretspec.resolver/1` sessions for callers without an async +//! runtime. +//! +//! [`crate::client::Client`] multiplexes concurrent calls over one transport, +//! which needs a reactor and therefore a Tokio dependency. A synchronous +//! consumer such as a build tool resolves one name at a time and needs neither. +//! This module keeps the canonical framing, envelopes, and validation and +//! trades only multiplexing for `std::process` and blocking pipe I/O, so a +//! program that has no runtime can speak the same wire protocol without +//! acquiring one. +//! +//! One call is in flight at a time, which is why no pending map, in-flight +//! permit, or cancellation arbitration appears here. Requests still carry their +//! wire deadline, and [`Watchdog`] enforces it locally. +//! +//! A session opened here advertises no callbacks (0.20+), so the endpoint never +//! sends one and an inbound request stays as fatal as any other envelope this +//! side did not ask for. That also means a `prompt = true` declaration with no +//! stored value resolves as missing rather than reaching a person, even though +//! a build tool on a terminal is exactly the consumer that could answer. +//! Servicing a callback between writing a request and reading its response +//! would fit this loop naturally, and is not implemented. + +use crate::deadline::{clamp_unix_ms, duration_until_unix_ms}; +use crate::error::ErrorKind; +use crate::frame::{FrameDecoder, encode}; +use crate::jsonrpc::{Envelope, Request, RequestId, Response}; +use crate::launch::{Environment, LaunchOptions}; +use crate::protocol::resolver::{ + self as resolver_protocol, DeleteParams, DeleteResult, GetParams, GetResult, + InitializeApplication, InitializedApplication, ReleaseParams, ReleaseResult, SetParams, + SetResult, +}; +use crate::protocol::{ + InitializeParams, InitializeResult, Limits, PROTOCOL_VERSION, Product, RESOLVER_PROTOCOL, rpc, +}; +use crate::{ABSOLUTE_MAX_FRAME_BYTES, Error, Result}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::collections::{HashSet, VecDeque}; +use std::io::{Read, Write}; +use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use zeroize::Zeroizing; + +/// Budget for reaping a child that had to be killed. Reaping is bounded local +/// work, so it is measured from the moment it starts rather than from a +/// caller's deadline, which a graceful wait has usually already spent. +const REAP_GRACE: Duration = Duration::from_secs(2); + +/// Poll interval while waiting for a child to exit on its own. `Child::wait` +/// would block past the caller's deadline, so exit is polled instead. +const WAIT_POLL: Duration = Duration::from_millis(10); + +/// Read size for the response pipe. Version 1 client frames are small, so this +/// buys full frames per syscall without reserving a frame-sized buffer. +const READ_CHUNK: usize = 8192; + +/// An initialized `secretspec.resolver/1` session and the child process that owns +/// its private transport. +pub struct ResolverSession { + transport: Transport, + capabilities: HashSet, + initialized: InitializedApplication, +} + +impl ResolverSession { + /// Launch an endpoint, complete initialization, and return a ready session. + /// + /// The child is killed and reaped if any part of the handshake fails, so a + /// rejected launch never leaves a process behind. + pub fn launch( + options: LaunchOptions, + client: Product, + limits: Limits, + application: InitializeApplication, + startup_deadline_unix_ms: u64, + ) -> Result { + application.validate()?; + let initialize = InitializeParams { + protocol: RESOLVER_PROTOCOL.to_string(), + versions: vec![PROTOCOL_VERSION], + client, + limits, + client_methods: Vec::new(), + application, + }; + // `initialize.protocol` is the constant compared against, so the + // protocol check inside `validate_common` is trivially true here. The + // call is kept for the checks that do bite locally: version list shape, + // product strings, and limit ranges. + initialize.validate_common(RESOLVER_PROTOCOL)?; + + let mut transport = Transport::spawn(&options)?; + let handshake = transport.initialize(&initialize, startup_deadline_unix_ms); + let initialized: InitializeResult = match handshake { + Ok(initialized) => initialized, + Err(error) => { + transport.terminate(); + return Err(error); + } + }; + + let mut session = Self { + transport, + capabilities: initialized.methods.into_iter().collect(), + initialized: initialized.application, + }; + if let Err(error) = session.validate_endpoint() { + session.transport.terminate(); + return Err(error); + } + Ok(session) + } + + fn validate_endpoint(&self) -> Result<()> { + self.initialized.validate()?; + if !resolver_protocol::CAPABILITIES + .iter() + .all(|method| self.capabilities.contains(*method)) + { + return Err(Error::Protocol( + "resolution endpoint did not advertise all required methods", + )); + } + Ok(()) + } + + /// Resolve one exact declared name on the session's fixed configuration. + pub fn get(&mut self, params: &GetParams, deadline_unix_ms: u64) -> Result { + params.validate()?; + self.call(resolver_protocol::method::GET, params, deadline_unix_ms) + } + + /// Store one exact declared name on the session's fixed configuration + /// (0.20+). + /// + /// The value lands wherever a [`Self::get`] of the same name would read it + /// from, so a consumer that stores and then resolves does not have to model + /// the endpoint's routing. Endpoints advertise `resolver.set` only when they + /// accept writes, and [`Self::call`] refuses to send an unadvertised method, + /// so an older or read-only resolver fails here rather than on the wire. + pub fn set(&mut self, params: &SetParams, deadline_unix_ms: u64) -> Result { + params.validate()?; + self.call(resolver_protocol::method::SET, params, deadline_unix_ms) + } + + /// Remove one exact declared name's stored value (0.20+). + /// + /// Advertised as `resolver.delete` under the same rule as [`Self::set`]. A + /// name the store never held reports `deleted: false` rather than failing. + pub fn delete(&mut self, params: &DeleteParams, deadline_unix_ms: u64) -> Result { + params.validate()?; + self.call(resolver_protocol::method::DELETE, params, deadline_unix_ms) + } + + /// Whether the endpoint advertised one method, such as + /// [`resolver_protocol::method::SET`]. + /// + /// A consumer that can explain a missing capability better than the + /// protocol can checks it here before building a request. + pub fn supports(&self, method: &str) -> bool { + self.capabilities.contains(method) + } + + /// Release path leases. Release is idempotent, so unknown IDs succeed. + pub fn release( + &mut self, + params: &ReleaseParams, + deadline_unix_ms: u64, + ) -> Result { + params.validate()?; + self.call(resolver_protocol::method::RELEASE, params, deadline_unix_ms) + } + + pub fn capabilities(&self) -> &HashSet { + &self.capabilities + } + + pub fn initialized(&self) -> &InitializedApplication { + &self.initialized + } + + pub fn is_closed(&self) -> bool { + self.transport.closed + } + + /// Shut the session down and reap the child. + /// + /// Path leases are released by disconnect, so a caller that only ever read + /// inline values does not need an explicit release first. + pub fn close(&mut self, deadline_unix_ms: u64) -> Result<()> { + self.transport.close(deadline_unix_ms) + } + + fn call( + &mut self, + method: &str, + params: &P, + deadline_unix_ms: u64, + ) -> Result { + if !self.capabilities.contains(method) { + return Err(Error::Protocol("method was not advertised")); + } + self.transport.call(method, params, deadline_unix_ms) + } +} + +/// The child, its pipes, and the incremental decoder for its responses. +struct Transport { + /// Shared so [`Watchdog`] can kill the child while this thread is parked in + /// a blocking read. Nothing holds this lock across an I/O call. + child: Arc>, + stdin: Option, + stdout: Receiver, + decoder: FrameDecoder, + frames: VecDeque>>, + next_id: u64, + max_frame_bytes: usize, + closed: bool, +} + +impl Transport { + fn spawn(options: &LaunchOptions) -> Result { + options.validate()?; + // Built before the spawn so nothing between here and the constructor + // can fail while a live child has no owner to reap it. + let decoder = FrameDecoder::new(ABSOLUTE_MAX_FRAME_BYTES)?; + let mut command = Command::new(&options.executable); + command + .args(&options.arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + match &options.environment { + Environment::Inherit(overrides) => { + command.envs(overrides); + } + Environment::Replace(environment) => { + command.env_clear().envs(environment); + } + } + + let mut child = command.spawn()?; + // Every pipe was requested above, so a missing one means the child is + // unusable. Reap it here rather than dropping a live process on the + // floor: nothing owns it yet, so no destructor would clean it up. + let Some(((stdin, stdout), stderr)) = child + .stdin + .take() + .zip(child.stdout.take()) + .zip(child.stderr.take()) + else { + let _ = child.kill(); + let _ = child.wait(); + return Err(Error::Protocol("child pipes were not created")); + }; + drain_stderr(stderr, options.max_stderr_bytes); + let stdout = read_stdout(stdout); + + Ok(Self { + child: Arc::new(Mutex::new(child)), + stdin: Some(stdin), + stdout, + decoder, + frames: VecDeque::new(), + next_id: 1, + max_frame_bytes: ABSOLUTE_MAX_FRAME_BYTES, + closed: false, + }) + } + + fn initialize( + &mut self, + initialize: &InitializeParams, + deadline_unix_ms: u64, + ) -> Result> { + let params = serde_json::to_value(initialize) + .map_err(|_| Error::Protocol("failed to serialize initialization"))?; + let value = self.exchange(rpc::INITIALIZE, params, deadline_unix_ms)?; + let initialized: InitializeResult = serde_json::from_value(value) + .map_err(|error| Error::ProtocolOwned(error.to_string()))?; + initialized.validate_common( + &initialize.protocol, + &initialize.versions, + initialize.limits, + )?; + // Both directions adopt the negotiated ceiling before the next call, so + // an oversized response is rejected by length rather than allocated. + self.max_frame_bytes = initialized.limits.max_frame_bytes; + self.decoder.set_limit(self.max_frame_bytes)?; + Ok(initialized) + } + + fn call( + &mut self, + method: &str, + params: &P, + deadline_unix_ms: u64, + ) -> Result { + let params = serde_json::to_value(params) + .map_err(|_| Error::Protocol("failed to serialize call params"))?; + let value = self.exchange(method, params, deadline_unix_ms)?; + serde_json::from_value(value).map_err(|error| Error::ProtocolOwned(error.to_string())) + } + + /// Send one request and wait for its response under a local deadline. + fn exchange(&mut self, method: &str, params: Value, deadline_unix_ms: u64) -> Result { + if self.closed { + return Err(Error::Closed); + } + // Clamp once, then use the same value locally and on the wire so the + // peer never enforces a longer deadline than this client waits for. + let deadline_unix_ms = clamp_unix_ms(deadline_unix_ms); + let remaining = duration_until_unix_ms(deadline_unix_ms); + if remaining.is_zero() { + return Err(Error::DeadlineExceeded); + } + let id = self.next_id()?; + let request = Request::new(id, method, deadline_unix_ms, params)?; + let deadline = Instant::now() + remaining; + + let watchdog = Watchdog::arm(&self.child, remaining); + let outcome = self.attempt(request, id, deadline); + let read_timed_out = self.closed && matches!(&outcome, Err(Error::DeadlineExceeded)); + // A watchdog that fired killed the transport, so every failure it + // produced downstream is really the deadline. A response that won the + // race is still valid and is reported as success; the session is dead + // either way and `close` reaps it. + let watchdog_fired = watchdog.disarm(); + if read_timed_out && !watchdog_fired { + self.stdin = None; + self.kill_and_reap(); + } + if watchdog_fired { + self.closed = true; + if outcome.is_err() { + return Err(Error::DeadlineExceeded); + } + } + outcome + } + + fn attempt(&mut self, request: Request, id: RequestId, deadline: Instant) -> Result { + let limit = self.max_frame_bytes; + self.write_envelope(&Envelope::Request(request), limit)?; + let response = self.read_response(deadline)?; + if response.id() != Some(id) { + // Strictly one call is in flight, so any other terminal ID is a + // protocol violation rather than something to correlate later. + self.closed = true; + return Err(Error::Protocol("response ID does not match the request")); + } + response_value(response) + } + + fn write_envelope(&mut self, envelope: &Envelope, limit: usize) -> Result<()> { + let payload = Zeroizing::new(envelope.to_vec()?); + let frame = Zeroizing::new(encode(&payload, limit)?); + let Some(stdin) = self.stdin.as_mut() else { + return Err(Error::Closed); + }; + if let Err(error) = stdin.write_all(&frame).and_then(|()| stdin.flush()) { + self.closed = true; + return Err(Error::Io(error)); + } + Ok(()) + } + + fn read_response(&mut self, deadline: Instant) -> Result { + loop { + if let Some(frame) = self.frames.pop_front() { + return match Envelope::parse(&frame) { + Ok(Envelope::Response(response)) => Ok(response), + // Notifications have no response channel. Structurally + // valid notifications, including methods introduced by a + // later revision, are ignored. + Ok(Envelope::Notification(_)) => continue, + // This client advertises no callbacks, so an inbound + // request remains a protocol violation. + Ok(Envelope::Request(_)) => { + self.closed = true; + Err(Error::Protocol("peer sent an unexpected request")) + } + Err(error) => { + self.closed = true; + Err(error) + } + }; + } + + let event = self + .stdout + .recv_timeout(deadline.saturating_duration_since(Instant::now())); + let buffer = match event { + Ok(StdoutEvent::Chunk(buffer)) => buffer, + Ok(StdoutEvent::Error(error)) => { + self.closed = true; + return Err(Error::Io(error)); + } + Ok(StdoutEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { + self.closed = true; + // EOF between frames is a clean disconnect; a partial frame is + // a truncation the caller must not confuse with one. + self.decoder.finish_eof()?; + return Err(Error::Closed); + } + Err(RecvTimeoutError::Timeout) => { + self.closed = true; + return Err(Error::DeadlineExceeded); + } + }; + if buffer.is_empty() { + self.closed = true; + return Err(Error::Protocol("stdout reader returned an empty chunk")); + } + match self.decoder.push(&buffer) { + Ok(frames) => self.frames.extend(frames), + Err(error) => { + self.closed = true; + return Err(error); + } + } + } + } + + fn next_id(&mut self) -> Result { + let id = RequestId::new(self.next_id)?; + self.next_id += 1; + Ok(id) + } + + fn close(&mut self, deadline_unix_ms: u64) -> Result<()> { + let outcome = if self.closed { + Ok(()) + } else { + self.exchange(rpc::SHUTDOWN, json!({}), deadline_unix_ms) + .and_then(|value| { + if value == json!({}) { + Ok(()) + } else { + Err(Error::Protocol("shutdown result is not empty")) + } + }) + }; + self.closed = true; + // Closing stdin is the disconnect signal an endpoint waits for, so it + // must happen before the graceful wait rather than at drop time. + self.stdin = None; + + let graceful = wait_until(&self.child, Instant::now() + REAP_GRACE); + if !matches!(graceful, Ok(true)) { + self.kill_and_reap(); + } + graceful?; + outcome + } + + /// Kill the child and reap it, so a session that failed or overran never + /// leaves a zombie behind. + fn terminate(&mut self) { + self.closed = true; + self.stdin = None; + self.kill_and_reap(); + } + + fn kill_and_reap(&mut self) { + let mut child = lock_unpoisoned(&self.child); + let _ = child.kill(); + drop(child); + // A killed child exits promptly, but the wait stays bounded so a + // grandchild holding the pipes cannot block the caller forever. + let _ = wait_until(&self.child, Instant::now() + REAP_GRACE); + } +} + +impl Drop for Transport { + fn drop(&mut self) { + if self.stdin.is_none() && self.closed { + return; + } + self.terminate(); + } +} + +enum StdoutEvent { + Chunk(Zeroizing>), + Eof, + Error(std::io::Error), +} + +/// Read stdout on its own thread and bound the handoff to one chunk. +/// +/// The receiver can enforce a request deadline even if a descendant inherited +/// the pipe and keeps this thread's blocking read alive after the direct child +/// is killed. Dropping the receiver makes the thread exit on its next read. +fn read_stdout(mut stdout: ChildStdout) -> Receiver { + let (sender, receiver) = sync_channel(1); + std::thread::spawn(move || { + loop { + let mut buffer = Zeroizing::new(vec![0_u8; READ_CHUNK]); + match stdout.read(&mut buffer) { + Ok(0) => { + send_stdout_event(&sender, StdoutEvent::Eof); + break; + } + Ok(read) => { + buffer.truncate(read); + if sender.send(StdoutEvent::Chunk(buffer)).is_err() { + break; + } + } + Err(error) => { + send_stdout_event(&sender, StdoutEvent::Error(error)); + break; + } + } + } + }); + receiver +} + +fn send_stdout_event(sender: &SyncSender, event: StdoutEvent) { + let _ = sender.send(event); +} + +/// Kills the direct child when a blocking call outlives its deadline. +/// +/// Stdout is read on a dedicated thread and the caller's channel receive has +/// its own timeout, so an inherited pipe cannot strand the caller. The +/// watchdog remains necessary for writes, which can also block when a peer +/// stops consuming input, and to end the direct child once either side stalls. +struct Watchdog { + finished: Arc<(Mutex, Condvar)>, + fired: Arc, + thread: Option>, +} + +impl Watchdog { + fn arm(child: &Arc>, timeout: Duration) -> Self { + let finished = Arc::new((Mutex::new(false), Condvar::new())); + let fired = Arc::new(AtomicBool::new(false)); + let thread = std::thread::spawn({ + let finished = Arc::clone(&finished); + let fired = Arc::clone(&fired); + let child = Arc::clone(child); + move || { + let (lock, condvar) = &*finished; + let guard = lock_unpoisoned(lock); + let (_guard, timeout_result) = condvar + .wait_timeout_while(guard, timeout, |finished| !*finished) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !timeout_result.timed_out() { + return; + } + // Ordered before the kill so the call that observes a dead + // transport always also observes the reason for it. + fired.store(true, Ordering::Release); + let _ = lock_unpoisoned(&child).kill(); + } + }); + Self { + finished, + fired, + thread: Some(thread), + } + } + + /// Stop the timer and report whether it had already fired. + fn disarm(mut self) -> bool { + let (lock, condvar) = &*self.finished; + *lock_unpoisoned(lock) = true; + condvar.notify_all(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + self.fired.load(Ordering::Acquire) + } +} + +/// Drain the child's stderr on a detached thread. +/// +/// Without a reader the child blocks once the pipe fills, which would deadlock +/// the session against a diagnostic it cannot finish writing. The retained +/// prefix is bounded by `max_stderr_bytes`; the remainder is read and dropped +/// so draining continues either way. +fn drain_stderr(mut stderr: ChildStderr, max_stderr_bytes: usize) { + std::thread::spawn(move || { + let mut retained = Zeroizing::new(Vec::with_capacity(max_stderr_bytes.min(READ_CHUNK))); + let mut buffer = Zeroizing::new(vec![0_u8; READ_CHUNK]); + loop { + let read = match stderr.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => read, + }; + let available = max_stderr_bytes.saturating_sub(retained.len()); + retained.extend_from_slice(&buffer[..read.min(available)]); + } + }); +} + +fn wait_until(child: &Arc>, deadline: Instant) -> Result { + loop { + if lock_unpoisoned(child).try_wait()?.is_some() { + return Ok(true); + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep(WAIT_POLL); + } +} + +fn response_value(response: Response) -> Result { + match response { + Response::Success(response) => Ok(response.result), + Response::Error(response) => match response.error.data.kind { + ErrorKind::Cancelled => Err(Error::Cancelled), + ErrorKind::DeadlineExceeded => Err(Error::DeadlineExceeded), + ErrorKind::Unavailable => Err(Error::Unavailable), + _ => Err(Error::Remote(response.error)), + }, + } +} + +fn lock_unpoisoned(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/secretspec-ipc/src/client.rs b/secretspec-ipc/src/client.rs new file mode 100644 index 000000000..14d106f9c --- /dev/null +++ b/secretspec-ipc/src/client.rs @@ -0,0 +1,824 @@ +use crate::deadline::instant_from_unix_ms; +use crate::error::{ErrorKind, RpcError}; +use crate::frame::{AsyncFrameReader, write_frame}; +use crate::jsonrpc::{Envelope, Notification, Request, RequestId, Response}; +use crate::protocol::{CancelParams, InitializeParams, InitializeResult, Limits, rpc}; +use crate::{ABSOLUTE_MAX_FRAME_BYTES, Error, Result}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, Weak}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use zeroize::{Zeroize, Zeroizing}; + +const INITIALIZING: u8 = 0; +const READY: u8 = 1; +const CLOSING: u8 = 2; +const CLOSED: u8 = 3; +const MAX_ABANDONED_REQUESTS: usize = crate::MAX_IN_FLIGHT * 4; + +enum WriterCommand { + Frame { + payload: Zeroizing>, + limit: usize, + }, + Close, +} + +/// Answers the server's callbacks on this session (0.20+). +/// +/// A client installs one only for the methods it advertised in +/// `client_methods`; a server never sends anything else. The returned +/// value is serialized as the callback's result, and an `RpcError` becomes its +/// error response, so declining is a normal outcome rather than a transport +/// failure. +#[async_trait::async_trait] +pub trait CallbackHandler: Send + Sync + 'static { + async fn call(&self, method: &str, params: Value) -> std::result::Result; +} + +struct Inner { + writer: mpsc::Sender, + callbacks: Option>, + /// The callbacks this client advertised. Fixed at initialization, so it + /// needs no lock and cannot drift from what the server was told. + advertised: HashSet, + inbound: StdMutex, + pending: StdMutex>, + abandoned: StdMutex>, + next_id: AtomicU64, + max_frame_bytes: AtomicUsize, + max_in_flight: AtomicUsize, + limits: RwLock, + semaphore: RwLock>, + capabilities: RwLock>, + state: AtomicU8, + closed: Notify, + reader_task: Mutex>>, + writer_task: Mutex>>, +} + +struct PendingRequest { + sender: oneshot::Sender, + deadline_unix_ms: u64, + cancellation: CancellationToken, +} + +#[derive(Default)] +struct InboundCallbacks { + active: usize, + last_seen_id: Option, +} + +/// A multiplexed SecretSpec JSON-RPC session. +#[derive(Clone)] +pub struct Client { + inner: Arc, +} + +impl Client { + /// Initialize a session on an already-authenticated private transport. + pub async fn connect( + reader: R, + writer: W, + initialize: InitializeParams, + startup_deadline_unix_ms: u64, + ) -> Result<(Self, InitializeResult)> + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + A: Serialize, + B: DeserializeOwned, + { + Self::connect_with_callbacks(reader, writer, initialize, startup_deadline_unix_ms, None) + .await + } + + /// As [`Self::connect`], installing a handler for the callbacks this + /// client advertised in `client_methods` (0.20+). + /// + /// Passing `None` while advertising a callback would leave the server + /// waiting out its deadline on a request nothing answers, so the two are + /// checked against each other here rather than at the first callback. + pub async fn connect_with_callbacks( + reader: R, + mut writer: W, + initialize: InitializeParams, + startup_deadline_unix_ms: u64, + callbacks: Option>, + ) -> Result<(Self, InitializeResult)> + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + A: Serialize, + B: DeserializeOwned, + { + if callbacks.is_none() && !initialize.client_methods.is_empty() { + return Err(Error::Protocol( + "client advertised a callback with no handler to answer it", + )); + } + let offered_protocol = initialize.protocol.clone(); + let offered_versions = initialize.versions.clone(); + let offered_limits = initialize.limits; + let advertised: HashSet = initialize.client_methods.iter().cloned().collect(); + // `offered_protocol` is a copy of `initialize.protocol`, so the protocol + // comparison inside `validate_common` is trivially true here. The call + // is kept for the checks that do bite locally: version list shape, + // product strings, and limit ranges. + initialize.validate_common(&offered_protocol)?; + let initialize_id = RequestId::new(1)?; + let initialize_params = serde_json::to_value(initialize) + .map_err(|_| Error::Protocol("failed to serialize initialization"))?; + let initialize_request = Request::new( + initialize_id, + rpc::INITIALIZE, + startup_deadline_unix_ms, + initialize_params, + )?; + + let (writer_tx, mut writer_rx) = + mpsc::channel::(offered_limits.max_in_flight.saturating_add(2)); + let mut reader = AsyncFrameReader::new(reader); + let inner = Arc::new(Inner { + writer: writer_tx, + callbacks, + advertised, + inbound: StdMutex::new(InboundCallbacks::default()), + pending: StdMutex::new(HashMap::new()), + abandoned: StdMutex::new(HashSet::new()), + next_id: AtomicU64::new(2), + max_frame_bytes: AtomicUsize::new(ABSOLUTE_MAX_FRAME_BYTES), + max_in_flight: AtomicUsize::new(1), + limits: RwLock::new(Limits::PRE_NEGOTIATION), + semaphore: RwLock::new(Arc::new(Semaphore::new(1))), + capabilities: RwLock::new(HashSet::new()), + state: AtomicU8::new(INITIALIZING), + closed: Notify::new(), + reader_task: Mutex::new(None), + writer_task: Mutex::new(None), + }); + + let writer_inner = Arc::downgrade(&inner); + let writer_task = tokio::spawn(async move { + while let Some(command) = writer_rx.recv().await { + match command { + WriterCommand::Frame { payload, limit } => { + if write_frame(&mut writer, &payload, limit).await.is_err() { + fail_session_weak(&writer_inner); + break; + } + } + WriterCommand::Close => break, + } + } + use tokio::io::AsyncWriteExt; + let _ = writer.shutdown().await; + }); + *inner.writer_task.lock().await = Some(writer_task); + + let reader_inner = Arc::downgrade(&inner); + let reader_task = tokio::spawn(async move { + loop { + let Some(inner) = reader_inner.upgrade() else { + break; + }; + let limit = inner.max_frame_bytes.load(Ordering::Acquire); + drop(inner); + let frame = match reader.read_frame(limit).await { + Ok(Some(frame)) => frame, + Ok(None) | Err(_) => { + fail_session_weak(&reader_inner); + break; + } + }; + let response = match Envelope::parse(&frame) { + Ok(Envelope::Response(response)) => response, + // A callback the client advertised. It is answered on its + // own task so this loop keeps reading: the same session + // still carries the responses this client is waiting for, + // and blocking here to ask a person would deadlock both + // sides until the deadline. + Ok(Envelope::Request(request)) => { + let Some(inner) = reader_inner.upgrade() else { + break; + }; + let Some(handler) = inner.callbacks.clone() else { + fail_session(&inner); + break; + }; + if !serve_callback(&inner, handler, request) { + fail_session(&inner); + break; + } + continue; + } + // Notifications have no response channel. Ignore unknown + // methods and malformed method-specific parameters after + // the strict envelope itself has parsed. + Ok(Envelope::Notification(_)) => continue, + _ => { + fail_session_weak(&reader_inner); + break; + } + }; + let Some(id) = response.id() else { + fail_session_weak(&reader_inner); + break; + }; + let Some(inner) = reader_inner.upgrade() else { + break; + }; + let pending = lock_unpoisoned(&inner.pending).remove(&id); + if let Some(pending) = pending { + // A deadline can race a response after marking this ID as + // abandoned but before removing it from `pending`. + lock_unpoisoned(&inner.abandoned).remove(&id); + pending.cancellation.cancel(); + let _ = pending.sender.send(response); + continue; + } + if lock_unpoisoned(&inner.abandoned).remove(&id) { + continue; + } + // Unknown and duplicate terminal IDs are protocol violations. + fail_session(&inner); + break; + } + }); + *inner.reader_task.lock().await = Some(reader_task); + + let client = Self { inner }; + let response = match client + .request_internal(initialize_request, ABSOLUTE_MAX_FRAME_BYTES) + .await + { + Ok(response) => response, + Err(error) => { + client.abort_transport().await; + return Err(error); + } + }; + let initialized = response_value(response) + .and_then(|value| { + serde_json::from_value::>(value) + .map_err(|error| Error::ProtocolOwned(error.to_string())) + }) + .and_then(|initialized| { + initialized.validate_common( + &offered_protocol, + &offered_versions, + offered_limits, + )?; + Ok(initialized) + }); + let initialized = match initialized { + Ok(initialized) => initialized, + Err(error) => { + client.abort_transport().await; + return Err(error); + } + }; + client + .inner + .max_frame_bytes + .store(initialized.limits.max_frame_bytes, Ordering::Release); + client + .inner + .max_in_flight + .store(initialized.limits.max_in_flight, Ordering::Release); + *client.inner.limits.write().await = initialized.limits; + *client.inner.semaphore.write().await = + Arc::new(Semaphore::new(initialized.limits.max_in_flight)); + *client.inner.capabilities.write().await = initialized.methods.iter().cloned().collect(); + client.inner.state.store(READY, Ordering::Release); + Ok((client, initialized)) + } + + pub async fn start( + &self, + method: &str, + params: &T, + deadline_unix_ms: u64, + ) -> Result { + if self.inner.state.load(Ordering::Acquire) != READY { + return Err(Error::Closed); + } + if !self.inner.capabilities.read().await.contains(method) { + return Err(Error::Protocol("method was not advertised")); + } + let params = serde_json::to_value(params) + .map_err(|_| Error::Protocol("failed to serialize call params"))?; + // Clamp once, then use the same value locally and on the wire so the + // peer never enforces a longer deadline than this client waits for. + let deadline_unix_ms = crate::deadline::clamp_unix_ms(deadline_unix_ms); + let deadline = instant_from_unix_ms(deadline_unix_ms); + if deadline <= Instant::now() { + return Err(Error::DeadlineExceeded); + } + let semaphore = self.inner.semaphore.read().await.clone(); + let permit = semaphore + .try_acquire_owned() + .map_err(|_| Error::Unavailable)?; + let id = self.next_id()?; + let request = Request::new(id, method, deadline_unix_ms, params)?; + let (sender, receiver) = oneshot::channel(); + lock_unpoisoned(&self.inner.pending).insert( + id, + PendingRequest { + sender, + deadline_unix_ms, + cancellation: CancellationToken::new(), + }, + ); + if let Err(error) = self.queue_request(request, deadline).await { + remove_pending(&self.inner, id); + return Err(error); + } + Ok(Call { + id, + deadline, + receiver: Some(receiver), + client: Arc::downgrade(&self.inner), + _permit: Some(permit), + terminal: false, + }) + } + + pub async fn call( + &self, + method: &str, + params: &P, + deadline_unix_ms: u64, + ) -> Result { + let mut call = self.start(method, params, deadline_unix_ms).await?; + let value = call.wait().await?; + serde_json::from_value(value).map_err(|error| Error::ProtocolOwned(error.to_string())) + } + + pub async fn limits(&self) -> Limits { + *self.inner.limits.read().await + } + + pub async fn capabilities(&self) -> HashSet { + self.inner.capabilities.read().await.clone() + } + + pub fn is_closed(&self) -> bool { + self.inner.state.load(Ordering::Acquire) == CLOSED + } + + /// Close the protocol session. The process launcher separately enforces + /// child termination and reaping after this wire shutdown completes. + pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> { + let state = + self.inner + .state + .compare_exchange(READY, CLOSING, Ordering::AcqRel, Ordering::Acquire); + let outcome = match state { + Ok(_) => { + let id = self.next_id()?; + let request = Request::new(id, rpc::SHUTDOWN, deadline_unix_ms, json!({}))?; + self.request_internal(request, self.inner.max_frame_bytes.load(Ordering::Acquire)) + .await + .and_then(|response| { + let value = response_value(response)?; + if value == json!({}) { + Ok(()) + } else { + Err(Error::Protocol("shutdown result is not empty")) + } + }) + } + Err(CLOSED) => Ok(()), + Err(_) => return Err(Error::Closed), + }; + + let _ = self.inner.writer.try_send(WriterCommand::Close); + let deadline = instant_from_unix_ms(deadline_unix_ms); + if let Some(mut task) = self.inner.writer_task.lock().await.take() + && tokio::time::timeout_at(deadline, &mut task).await.is_err() + { + task.abort(); + let _ = task.await; + } + if let Some(mut task) = self.inner.reader_task.lock().await.take() + && tokio::time::timeout_at(deadline, &mut task).await.is_err() + { + task.abort(); + let _ = task.await; + } + fail_session(&self.inner); + outcome + } + + /// Mark a transport dead from a process watcher without exposing pending + /// request data in an error. + pub async fn abandon(&self) { + fail_session(&self.inner); + } + + /// Let the transport reader consume frames already buffered in the pipe + /// before a process watcher declares the session dead. A child can write a + /// terminal response and exit before the reader task is scheduled; failing + /// the session immediately in that window would discard the valid reply. + pub(crate) async fn abandon_after_process_exit(&self, grace: Duration) { + if self.is_closed() { + return; + } + let closed = self.inner.closed.notified(); + tokio::pin!(closed); + closed.as_mut().enable(); + if self.is_closed() { + return; + } + if tokio::time::timeout(grace, closed).await.is_err() && !self.is_closed() { + fail_session(&self.inner); + } + } + + async fn abort_transport(&self) { + fail_session(&self.inner); + let _ = self.inner.writer.try_send(WriterCommand::Close); + if let Some(task) = self.inner.writer_task.lock().await.take() { + task.abort(); + let _ = task.await; + } + if let Some(task) = self.inner.reader_task.lock().await.take() { + task.abort(); + let _ = task.await; + } + } + + fn next_id(&self) -> Result { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + RequestId::new(id) + } + + async fn queue_request(&self, request: Request, deadline: Instant) -> Result<()> { + let envelope = Envelope::Request(request); + let payload = Zeroizing::new(envelope.to_vec()?); + let limit = self.inner.max_frame_bytes.load(Ordering::Acquire); + if payload.len() > limit { + return Err(Error::Protocol( + "request exceeds the negotiated frame limit", + )); + } + match tokio::time::timeout_at( + deadline, + self.inner + .writer + .send(WriterCommand::Frame { payload, limit }), + ) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(Error::Closed), + Err(_) => Err(Error::DeadlineExceeded), + } + } + + async fn request_internal(&self, request: Request, limit: usize) -> Result { + let id = request.id; + let deadline_unix_ms = request.deadline_unix_ms(); + let deadline = instant_from_unix_ms(deadline_unix_ms); + let (sender, receiver) = oneshot::channel(); + lock_unpoisoned(&self.inner.pending).insert( + id, + PendingRequest { + sender, + deadline_unix_ms, + cancellation: CancellationToken::new(), + }, + ); + let payload = match Envelope::Request(request).to_vec() { + Ok(payload) => Zeroizing::new(payload), + Err(error) => { + remove_pending(&self.inner, id); + return Err(error); + } + }; + if payload.len() > limit { + remove_pending(&self.inner, id); + return Err(Error::Protocol("request exceeds the active frame limit")); + } + match tokio::time::timeout_at( + deadline, + self.inner + .writer + .send(WriterCommand::Frame { payload, limit }), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(_)) => { + remove_pending(&self.inner, id); + return Err(Error::Closed); + } + Err(_) => { + remove_pending(&self.inner, id); + return Err(Error::DeadlineExceeded); + } + } + match tokio::time::timeout_at(deadline, receiver).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Err(Error::Closed), + Err(_) => { + abandon_request(&self.inner, id); + Err(Error::DeadlineExceeded) + } + } + } +} + +/// One in-flight call. Exactly one task may wait on a call; cancellation may +/// race that waiter through `cancel`. +pub struct Call { + id: RequestId, + deadline: Instant, + receiver: Option>, + client: Weak, + _permit: Option, + terminal: bool, +} + +impl Call { + pub const fn id(&self) -> RequestId { + self.id + } + + pub async fn cancel(&self) -> Result<()> { + let Some(client) = self.client.upgrade() else { + return Err(Error::Closed); + }; + cancel_parent_callbacks(&client, self.id); + match tokio::time::timeout_at(self.deadline, queue_cancel(&client, self.id)).await { + Ok(result) => result, + Err(_) => Err(Error::DeadlineExceeded), + } + } + + pub async fn wait(&mut self) -> Result { + let receiver = self + .receiver + .take() + .ok_or(Error::Protocol("call has already been waited"))?; + let response = match tokio::time::timeout_at(self.deadline, receiver).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + self.terminal = true; + self._permit.take(); + return Err(Error::Closed); + } + Err(_) => { + if let Some(client) = self.client.upgrade() { + abandon_request(&client, self.id); + try_queue_cancel(&client, self.id); + } + self.terminal = true; + self._permit.take(); + return Err(Error::DeadlineExceeded); + } + }; + self.terminal = true; + self._permit.take(); + response_value(response) + } +} + +impl Drop for Call { + fn drop(&mut self) { + if self.terminal { + return; + } + if let Some(client) = self.client.upgrade() { + abandon_request(&client, self.id); + try_queue_cancel(&client, self.id); + } + } +} + +async fn queue_cancel(inner: &Arc, id: RequestId) -> Result<()> { + let notification = Notification::new( + rpc::CANCEL, + serde_json::to_value(CancelParams { id }) + .map_err(|_| Error::Protocol("failed to serialize cancellation"))?, + )?; + let payload = Zeroizing::new(Envelope::Notification(notification).to_vec()?); + let limit = inner.max_frame_bytes.load(Ordering::Acquire); + inner + .writer + .send(WriterCommand::Frame { payload, limit }) + .await + .map_err(|_| Error::Closed) +} + +fn try_queue_cancel(inner: &Arc, id: RequestId) { + let notification = serde_json::to_value(CancelParams { id }) + .ok() + .and_then(|params| Notification::new(rpc::CANCEL, params).ok()) + .and_then(|notification| Envelope::Notification(notification).to_vec().ok()) + .map(Zeroizing::new); + if let Some(payload) = notification { + let limit = inner.max_frame_bytes.load(Ordering::Acquire); + let _ = inner + .writer + .try_send(WriterCommand::Frame { payload, limit }); + } +} + +/// Accept one inbound callback and answer it on its own task. +/// +/// Returns `false` for a request this session must not accept at all: the +/// server reusing an inbound ID, naming a method the client never advertised, +/// or exceeding the negotiated active or bounded session-ID limits. Each means +/// the peer is not tracking the session state this side is, which is a protocol +/// violation rather than an application error. +/// +/// Completed IDs remain in a bounded session set so reuse is still rejected +/// after a callback finishes. Active callbacks are bounded independently by +/// the negotiated in-flight limit. +fn serve_callback(inner: &Arc, handler: Arc, request: Request) -> bool { + if !inner.advertised.contains(&request.method) { + return false; + } + let Some(parent_id) = request.meta.parent_request_id else { + return false; + }; + let parent_cancellation = { + // Hold the parent table while reserving the inbound slot. A terminal + // response cannot remove the parent between validation and attaching + // the callback's cancellation state. + let pending = lock_unpoisoned(&inner.pending); + let Some(parent) = pending.get(&parent_id) else { + return false; + }; + if request.deadline_unix_ms() > parent.deadline_unix_ms { + return false; + } + let mut inbound = lock_unpoisoned(&inner.inbound); + let active_limit = inner.max_in_flight.load(Ordering::Acquire); + if inbound.active >= active_limit + || inbound.last_seen_id.is_some_and(|last| request.id <= last) + { + return false; + } + inbound.last_seen_id = Some(request.id); + inbound.active += 1; + parent.cancellation.clone() + }; + let deadline = instant_from_unix_ms(request.deadline_unix_ms()); + let task_inner = Arc::downgrade(inner); + tokio::spawn(async move { + let mut outcome = tokio::select! { + biased; + _ = parent_cancellation.cancelled() => Err(RpcError::new(ErrorKind::Cancelled)), + outcome = tokio::time::timeout_at( + deadline, + handler.call(&request.method, request.params), + ) => outcome.unwrap_or_else(|_| Err(RpcError::new(ErrorKind::DeadlineExceeded))), + }; + // A terminal parent may race a ready handler. Cancellation wins and + // any returned secret is scrubbed before it can be serialized. + if parent_cancellation.is_cancelled() { + if let Ok(value) = &mut outcome { + zeroize_json(value); + } + outcome = Err(RpcError::new(ErrorKind::Cancelled)); + } + let mut response = match outcome { + Ok(result) => Response::success(request.id, result), + Err(error) => Response::error(Some(request.id), error), + }; + let Some(inner) = task_inner.upgrade() else { + zeroize_response(&mut response); + return; + }; + { + let mut inbound = lock_unpoisoned(&inner.inbound); + inbound.active = inbound.active.saturating_sub(1); + } + let limit = inner.max_frame_bytes.load(Ordering::Acquire); + let encoded = serde_json::to_vec(&response).map(Zeroizing::new); + zeroize_response(&mut response); + let payload = match encoded { + Ok(payload) if payload.len() <= limit => payload, + // An answer that cannot fit still owes the server one terminal + // frame, or its callback would hang until the deadline. + _ => { + let replacement = + Response::error(Some(request.id), RpcError::new(ErrorKind::MessageTooLarge)); + match Envelope::Response(replacement).to_vec().map(Zeroizing::new) { + Ok(payload) => payload, + Err(_) => return, + } + } + }; + let send = inner.writer.send(WriterCommand::Frame { payload, limit }); + tokio::select! { + biased; + _ = parent_cancellation.cancelled() => { + // Dropping the competing send zeroizes its serialized payload. + // Only a value-free cancellation may be queued after the + // parent has become terminal. + let replacement = + Response::error(Some(request.id), RpcError::new(ErrorKind::Cancelled)); + if let Ok(payload) = Envelope::Response(replacement).to_vec().map(Zeroizing::new) { + let _ = inner + .writer + .send(WriterCommand::Frame { payload, limit }) + .await; + } + } + _ = send => {} + } + }); + true +} + +fn response_value(response: Response) -> Result { + match response { + Response::Success(response) => Ok(response.result), + Response::Error(response) => match response.error.data.kind { + ErrorKind::Cancelled => Err(Error::Cancelled), + ErrorKind::DeadlineExceeded => Err(Error::DeadlineExceeded), + ErrorKind::Unavailable => Err(Error::Unavailable), + _ => Err(Error::Remote(response.error)), + }, + } +} + +fn fail_session_weak(inner: &Weak) { + if let Some(inner) = inner.upgrade() { + fail_session(&inner); + } +} + +fn fail_session(inner: &Arc) { + inner.state.store(CLOSED, Ordering::Release); + for (_, pending) in lock_unpoisoned(&inner.pending).drain() { + pending.cancellation.cancel(); + } + lock_unpoisoned(&inner.abandoned).clear(); + inner.closed.notify_waiters(); +} + +fn abandon_request(inner: &Arc, id: RequestId) { + // Mark first, then remove from pending. The response reader removes the + // marker if it wins the race while the pending sender still exists. + let overflow = { + let mut abandoned = lock_unpoisoned(&inner.abandoned); + if abandoned.len() >= MAX_ABANDONED_REQUESTS { + true + } else { + abandoned.insert(id); + false + } + }; + remove_pending(inner, id); + if overflow { + // A peer that never terminates cancelled/timed-out requests cannot + // grow client memory without bound. Close and reconnect instead. + fail_session(inner); + } +} + +fn cancel_parent_callbacks(inner: &Arc, id: RequestId) { + if let Some(pending) = lock_unpoisoned(&inner.pending).get(&id) { + pending.cancellation.cancel(); + } +} + +fn remove_pending(inner: &Arc, id: RequestId) { + if let Some(pending) = lock_unpoisoned(&inner.pending).remove(&id) { + pending.cancellation.cancel(); + } +} + +fn zeroize_response(response: &mut Response) { + if let Response::Success(response) = response { + zeroize_json(&mut response.result); + } +} + +fn zeroize_json(value: &mut Value) { + match value { + Value::String(value) => value.zeroize(), + Value::Array(values) => values.iter_mut().for_each(zeroize_json), + Value::Object(values) => values.values_mut().for_each(zeroize_json), + _ => {} + } +} + +fn lock_unpoisoned(mutex: &StdMutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/secretspec-ipc/src/deadline.rs b/secretspec-ipc/src/deadline.rs new file mode 100644 index 000000000..0a148762e --- /dev/null +++ b/secretspec-ipc/src/deadline.rs @@ -0,0 +1,120 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Returns an absolute Unix-millisecond deadline after `duration`. +/// +/// Saturation keeps the wire value valid even when the wall clock or duration +/// is close to the representable limit. +pub fn unix_ms_after(duration: Duration) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .saturating_add(duration.as_millis()) + .min(u64::MAX as u128) as u64 +} + +/// Largest interval a peer-supplied deadline may place in the future. +/// +/// The wire type is an unbounded `u64`, so without this a peer could name a +/// deadline centuries away. The timer would then never fire, the request would +/// hold its in-flight permit for the life of the process, and no amount of +/// server-side timeout logic would reclaim it. Clamping (rather than rejecting) +/// keeps a generous caller working while bounding what a hostile one can hold. +pub const MAX_DEADLINE_HORIZON: Duration = Duration::from_secs(300); + +/// Clamps a wire deadline to [`MAX_DEADLINE_HORIZON`] past now. +/// +/// Senders apply this so the value they put on the wire matches the deadline +/// they enforce locally; a receiver that clamped only locally would let the +/// peer believe it had longer than the sender was actually willing to wait. +pub fn clamp_unix_ms(deadline_unix_ms: u64) -> u64 { + unix_ms_after(MAX_DEADLINE_HORIZON).min(deadline_unix_ms) +} + +/// Time left until an absolute Unix-millisecond deadline, clamped to +/// [`MAX_DEADLINE_HORIZON`]. An already-elapsed deadline yields zero so callers +/// reject it as expired instead of waiting. +/// +/// This is the runtime-independent half of deadline handling: a blocking caller +/// needs a `Duration` to hand to a timed wait, and the async transports build +/// their `Instant` from the same value so both enforce the identical horizon. +pub fn duration_until_unix_ms(deadline_unix_ms: u64) -> Duration { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64; + Duration::from_millis(deadline_unix_ms.saturating_sub(now_ms)).min(MAX_DEADLINE_HORIZON) +} + +/// Converts an absolute Unix-millisecond deadline into a monotonic instant, +/// clamped to [`MAX_DEADLINE_HORIZON`] past now. An already-elapsed deadline +/// yields the current instant so callers reject it as expired. +#[cfg(feature = "tokio")] +pub(crate) fn instant_from_unix_ms(deadline_unix_ms: u64) -> tokio::time::Instant { + tokio::time::Instant::now() + duration_until_unix_ms(deadline_unix_ms) +} + +/// Converts a monotonic deadline back into the absolute wire form, for a +/// callback that must not outlive the request that raised it. Rounding is +/// downward, so a derived deadline is never later than the one it came from. +#[cfg(feature = "tokio")] +pub(crate) fn unix_ms_from_instant(deadline: tokio::time::Instant) -> u64 { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + unix_ms_after(remaining) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_deadline_is_not_in_the_past() { + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + assert!(unix_ms_after(Duration::from_secs(1)) >= before.saturating_add(1_000)); + } + + #[cfg(feature = "tokio")] + #[tokio::test] + async fn far_future_deadlines_are_clamped_to_the_horizon() { + // A peer naming a deadline centuries out must not win an unbounded + // in-flight permit; the horizon caps what it can hold. The ceiling is + // sampled after the call so its `now` is never earlier than the + // function's own. + let saturated = instant_from_unix_ms(u64::MAX); + let far = instant_from_unix_ms(unix_ms_after(Duration::from_secs(86_400))); + let ceiling = tokio::time::Instant::now() + MAX_DEADLINE_HORIZON; + assert!(saturated <= ceiling); + assert!(far <= ceiling); + } + + #[cfg(feature = "tokio")] + #[tokio::test] + async fn deadlines_inside_the_horizon_are_preserved() { + let floor = tokio::time::Instant::now() + Duration::from_secs(25); + let instant = instant_from_unix_ms(unix_ms_after(Duration::from_secs(30))); + let ceiling = tokio::time::Instant::now() + Duration::from_secs(31); + assert!(instant > floor); + assert!(instant <= ceiling); + } + + #[cfg(feature = "tokio")] + #[test] + fn clamping_a_wire_deadline_is_idempotent_and_bounded() { + let clamped = clamp_unix_ms(u64::MAX); + assert!(clamped <= unix_ms_after(MAX_DEADLINE_HORIZON)); + // A deadline already inside the horizon passes through untouched. + let near = unix_ms_after(Duration::from_secs(5)); + assert_eq!(clamp_unix_ms(near), near); + } + + #[test] + fn horizon_covers_the_documented_operation_timeout() { + // External provider operations use a 30s timeout and startup 10s; the + // horizon must not clamp SecretSpec's own legitimate deadlines. + assert!(MAX_DEADLINE_HORIZON >= Duration::from_secs(30)); + } +} diff --git a/secretspec-ipc/src/description.rs b/secretspec-ipc/src/description.rs new file mode 100644 index 000000000..73f9dad4d --- /dev/null +++ b/secretspec-ipc/src/description.rs @@ -0,0 +1,163 @@ +use crate::protocol::{PROVIDER_PROTOCOL, Product, RESOLVER_PROTOCOL}; +use crate::{ABSOLUTE_MAX_FRAME_BYTES, Error, Result}; +use serde_json::{Map, Value, json}; + +const COMMON_SCHEMA: &str = include_str!("../schema/ipc/v1/common.schema.json"); +const RESOLVER_SCHEMA: &str = include_str!("../schema/ipc/v1/resolver.schema.json"); +const PROVIDER_SCHEMA: &str = include_str!("../schema/ipc/v1/provider.schema.json"); +const RESOLVER_OPENRPC: &str = include_str!("../schema/ipc/v1/resolver.openrpc.json"); +const PROVIDER_OPENRPC: &str = include_str!("../schema/ipc/v1/provider.openrpc.json"); + +/// Build the endpoint's self-contained OpenRPC discovery document. +/// +/// The checked-in OpenRPC files use relative references because they are also +/// published as standalone artifacts. Runtime discovery cannot assume a +/// network connection or a source checkout, so every common and role-specific +/// schema definition is embedded under `components.schemas` and every `$ref` +/// is rewritten to point into the returned document. +pub(crate) fn openrpc( + protocol: &str, + versions: &[u32], + product: &Product, + methods: &[String], +) -> Result { + let (document_source, schema_source, namespace) = match protocol { + RESOLVER_PROTOCOL => (RESOLVER_OPENRPC, RESOLVER_SCHEMA, "resolver"), + PROVIDER_PROTOCOL => (PROVIDER_OPENRPC, PROVIDER_SCHEMA, "provider"), + _ => return Err(Error::Protocol("unknown application protocol")), + }; + + let mut document: Value = parse(document_source)?; + rewrite_refs(&mut document, namespace); + + let common_schema: Value = parse(COMMON_SCHEMA)?; + let application_schema: Value = parse(schema_source)?; + let mut schemas = Map::new(); + append_definitions(&mut schemas, &common_schema, "common")?; + append_definitions(&mut schemas, &application_schema, namespace)?; + + let root = document + .as_object_mut() + .ok_or(Error::Protocol("OpenRPC document is not an object"))?; + let components = root + .entry("components") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or(Error::Protocol("OpenRPC components is not an object"))?; + components.insert("schemas".to_string(), Value::Object(schemas)); + root.insert( + "x-secretspec".to_string(), + json!({ + "protocol": protocol, + "versions": versions, + "server": product, + "methods": methods, + "absolute_max_frame_bytes": ABSOLUTE_MAX_FRAME_BYTES, + }), + ); + + let encoded = serde_json::to_vec(&document) + .map_err(|_| Error::Protocol("failed to serialize OpenRPC document"))?; + if encoded.len() > ABSOLUTE_MAX_FRAME_BYTES { + return Err(Error::Protocol("OpenRPC document exceeds the frame limit")); + } + Ok(document) +} + +fn parse(source: &str) -> Result { + serde_json::from_str(source) + .map_err(|_| Error::Protocol("embedded protocol description is invalid")) +} + +fn append_definitions( + output: &mut Map, + schema: &Value, + namespace: &str, +) -> Result<()> { + let definitions = schema + .get("$defs") + .and_then(Value::as_object) + .ok_or(Error::Protocol("embedded schema has no definitions"))?; + for (name, definition) in definitions { + let mut definition = definition.clone(); + rewrite_refs(&mut definition, namespace); + output.insert(format!("{namespace}.{name}"), definition); + } + Ok(()) +} + +fn rewrite_refs(value: &mut Value, local_namespace: &str) { + match value { + Value::Array(items) => { + for item in items { + rewrite_refs(item, local_namespace); + } + } + Value::Object(object) => { + if let Some(Value::String(reference)) = object.get_mut("$ref") { + if let Some(rewritten) = rewrite_reference(reference, local_namespace) { + *reference = rewritten; + } + } + for item in object.values_mut() { + rewrite_refs(item, local_namespace); + } + } + _ => {} + } +} + +fn rewrite_reference(reference: &str, local_namespace: &str) -> Option { + if let Some(name) = reference.strip_prefix("#/$defs/") { + return Some(format!("#/components/schemas/{local_namespace}.{name}")); + } + for namespace in ["common", "resolver", "provider"] { + let prefix = format!("{namespace}.schema.json#/$defs/"); + if let Some(name) = reference.strip_prefix(&prefix) { + return Some(format!("#/components/schemas/{namespace}.{name}")); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_internal_refs(value: &Value) { + match value { + Value::Array(items) => items.iter().for_each(assert_internal_refs), + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + assert!( + reference.starts_with("#/components/"), + "runtime discovery retained an external reference: {reference}" + ); + } + object.values().for_each(assert_internal_refs); + } + _ => {} + } + } + + #[test] + fn discovery_documents_are_self_contained_and_bounded() { + let product = Product { + name: "test-endpoint".to_string(), + version: "1".to_string(), + }; + for (protocol, methods) in [ + (RESOLVER_PROTOCOL, vec!["resolver.get".to_string()]), + ( + PROVIDER_PROTOCOL, + vec!["provider.resolve_address".to_string()], + ), + ] { + let document = openrpc(protocol, &[1], &product, &methods).unwrap(); + assert_internal_refs(&document); + assert_eq!(document["x-secretspec"]["protocol"], protocol); + assert_eq!(document["x-secretspec"]["server"]["name"], "test-endpoint"); + assert!(serde_json::to_vec(&document).unwrap().len() < ABSOLUTE_MAX_FRAME_BYTES); + } + } +} diff --git a/secretspec-ipc/src/error.rs b/secretspec-ipc/src/error.rs new file mode 100644 index 000000000..8ef57b820 --- /dev/null +++ b/secretspec-ipc/src/error.rs @@ -0,0 +1,505 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use thiserror::Error; + +/// The provider-owned interaction that must finish before an operation can be +/// retried. +/// +/// The set is closed for senders and open for receivers for the same reason as +/// [`ErrorKind`]: adding another interaction kind must not make an older client +/// reject an otherwise well-formed error response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InteractionKind { + Authorization, + Unrecognized, +} + +impl InteractionKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Authorization => "authorization", + Self::Unrecognized => "unrecognized", + } + } + + pub fn from_wire(kind: &str) -> Self { + match kind { + "authorization" => Self::Authorization, + _ => Self::Unrecognized, + } + } +} + +impl Serialize for InteractionKind { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for InteractionKind { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + Ok(Self::from_wire(&String::deserialize(deserializer)?)) + } +} + +/// Opaque, non-secret correlation data for provider-owned interaction. +/// Available starting with SecretSpec 0.20. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InteractionReference { + pub kind: InteractionKind, + pub id: String, + #[serde(deserialize_with = "crate::protocol::deserialize_required_nullable")] + pub expires_at_unix_ms: Option, +} + +impl InteractionReference { + pub fn authorization(id: impl Into, expires_at_unix_ms: Option) -> Self { + Self { + kind: InteractionKind::Authorization, + id: id.into(), + expires_at_unix_ms, + } + } + + fn validate(&self) -> std::result::Result<(), &'static str> { + if self.id.is_empty() + || self.id.len() > 128 + || !self + .id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("interaction id has an invalid format"); + } + if self.expires_at_unix_ms == Some(0) { + return Err("interaction expiry must be positive"); + } + Ok(()) + } +} + +/// Stable machine-readable error kinds shared by both application protocols. +/// +/// The set is closed for senders and open for receivers. An endpoint emits only +/// the kinds below, but a receiver decodes anything it does not recognize as +/// [`ErrorKind::Unrecognized`] instead of failing the frame. Without that, the +/// first kind a later protocol version adds would kill every session with an +/// older peer, and the set could never grow without a new protocol version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + ParseError, + InvalidRequest, + MethodNotFound, + InvalidParams, + Internal, + UnsupportedVersion, + CapabilityRequired, + DeadlineExceeded, + Cancelled, + Unavailable, + PermissionDenied, + InteractionRequired, + Conflict, + OperationFailed, + MessageTooLarge, + RepresentationMismatch, + /// A kind this implementation does not know, decoded from a peer that + /// speaks a later revision of the protocol. + /// + /// Receiving one means the operation failed for a reason this side cannot + /// name, so it is handled as a failure and never as a success. An endpoint + /// MUST NOT construct it: doing so would put `"unrecognized"` on the wire + /// as if it were a defined kind. + Unrecognized, +} + +impl ErrorKind { + /// Stable wire spelling used for structured handling and redacted audit + /// records. + pub const fn as_str(self) -> &'static str { + match self { + Self::ParseError => "parse_error", + Self::InvalidRequest => "invalid_request", + Self::MethodNotFound => "method_not_found", + Self::InvalidParams => "invalid_params", + Self::Internal => "internal", + Self::UnsupportedVersion => "unsupported_version", + Self::CapabilityRequired => "capability_required", + Self::DeadlineExceeded => "deadline_exceeded", + Self::Cancelled => "cancelled", + Self::Unavailable => "unavailable", + Self::PermissionDenied => "permission_denied", + Self::InteractionRequired => "interaction_required", + Self::Conflict => "conflict", + Self::OperationFailed => "operation_failed", + Self::MessageTooLarge => "message_too_large", + Self::RepresentationMismatch => "representation_mismatch", + Self::Unrecognized => "unrecognized", + } + } + + /// Decode a wire spelling, mapping anything unknown to + /// [`Self::Unrecognized`] rather than refusing the frame. + pub fn from_wire(kind: &str) -> Self { + Self::DEFINED + .iter() + .copied() + .find(|defined| defined.as_str() == kind) + .unwrap_or(Self::Unrecognized) + } + + /// The kind a defined code names, or `None` for a code this revision does + /// not define. Used to keep a peer honest: a code this side knows must + /// arrive with the kind that belongs to it, and only a code it does not + /// know may carry a kind it does not know. + pub fn from_code(code: i32) -> Option { + Self::DEFINED + .iter() + .copied() + .find(|defined| defined.code() == code) + } + + /// Every kind this revision defines, in wire-code order. `Unrecognized` is + /// deliberately absent: it is a decoding outcome, not a defined kind. + pub const DEFINED: &'static [Self] = &[ + Self::ParseError, + Self::InvalidRequest, + Self::MethodNotFound, + Self::InvalidParams, + Self::Internal, + Self::UnsupportedVersion, + Self::CapabilityRequired, + Self::DeadlineExceeded, + Self::Cancelled, + Self::Unavailable, + Self::PermissionDenied, + Self::InteractionRequired, + Self::Conflict, + Self::OperationFailed, + Self::MessageTooLarge, + Self::RepresentationMismatch, + ]; + + pub const fn code(self) -> i32 { + match self { + Self::ParseError => -32700, + Self::InvalidRequest => -32600, + Self::MethodNotFound => -32601, + Self::InvalidParams => -32602, + Self::Internal => -32603, + Self::UnsupportedVersion => -32000, + Self::CapabilityRequired => -32001, + Self::DeadlineExceeded => -32002, + Self::Cancelled => -32003, + Self::Unavailable => -32004, + Self::PermissionDenied => -32005, + Self::InteractionRequired => -32006, + Self::Conflict => -32007, + Self::OperationFailed => -32008, + Self::MessageTooLarge => -32009, + Self::RepresentationMismatch => -32010, + // Never sent, so it names no code of its own. The wire code that + // arrived with it is carried by `RpcError::code`. + Self::Unrecognized => 0, + } + } + + pub const fn message(self) -> &'static str { + match self { + Self::ParseError => "parse error", + Self::InvalidRequest => "invalid request", + Self::MethodNotFound => "method not found", + Self::InvalidParams => "invalid params", + Self::Internal => "internal error", + Self::UnsupportedVersion => "unsupported version", + Self::CapabilityRequired => "capability required", + Self::DeadlineExceeded => "deadline exceeded", + Self::Cancelled => "cancelled", + Self::Unavailable => "unavailable", + Self::PermissionDenied => "permission denied", + Self::InteractionRequired => "interaction required", + Self::Conflict => "conflict", + Self::OperationFailed => "operation failed", + Self::MessageTooLarge => "message too large", + Self::RepresentationMismatch => "representation mismatch", + Self::Unrecognized => "unrecognized error", + } + } + + pub const fn retryable_by_default(self) -> bool { + matches!(self, Self::Unavailable) + } +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Serialize for ErrorKind { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ErrorKind { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + // Deliberately infallible for any string. Refusing an unknown kind here + // would turn every later addition into a session-killing parse failure + // against an older peer, which is exactly what the open receiver rule + // exists to prevent. `RpcError::validate` still rejects an unknown kind + // that arrived with a code this revision does define. + Ok(Self::from_wire(&String::deserialize(deserializer)?)) + } +} + +/// Closed JSON-RPC `error.data` payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ErrorData { + pub kind: ErrorKind, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interaction: Option, +} + +/// A stable, redacted JSON-RPC error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RpcError { + pub code: i32, + pub message: String, + pub data: ErrorData, +} + +impl RpcError { + pub fn new(kind: ErrorKind) -> Self { + Self { + code: kind.code(), + message: kind.message().to_string(), + data: ErrorData { + kind, + retryable: kind.retryable_by_default(), + retry_after_ms: None, + interaction: None, + }, + } + } + + pub fn interaction_required(reference: Option) -> Self { + let mut error = Self::new(ErrorKind::InteractionRequired); + error.data.interaction = reference; + error + } + + pub fn unavailable(retry_after_ms: Option) -> Self { + let mut error = Self::new(ErrorKind::Unavailable); + error.data.retry_after_ms = retry_after_ms; + error + } + + pub fn validate(&self) -> std::result::Result<(), &'static str> { + if self.data.kind == ErrorKind::Unrecognized { + // A code this revision defines must arrive with the kind that + // belongs to it. Only a code it has never heard of may carry a kind + // it has never heard of, which is what a later revision sends. + if ErrorKind::from_code(self.code).is_some() { + return Err("defined error code arrived with an undefined kind"); + } + } else if self.code != self.data.kind.code() { + return Err("error code does not match error kind"); + } + if self.message.is_empty() || self.message.len() > 256 { + return Err("error message has an invalid byte length"); + } + if let Some(retry_after_ms) = self.data.retry_after_ms { + // Allowed alongside an unrecognized kind because a later revision + // may define another retryable one, and refusing it here would make + // that addition unreachable. + if !matches!( + self.data.kind, + ErrorKind::Unavailable | ErrorKind::Unrecognized + ) { + return Err("retry_after_ms is only valid for unavailable"); + } + if retry_after_ms == 0 { + return Err("retry_after_ms must be positive"); + } + } + if let Some(interaction) = &self.data.interaction { + if self.data.kind != ErrorKind::InteractionRequired { + return Err("interaction is only valid for interaction_required"); + } + interaction.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error")] + Io(#[from] std::io::Error), + #[error("protocol error: {0}")] + Protocol(&'static str), + #[error("protocol error: {0}")] + ProtocolOwned(String), + #[error("remote {0:?}")] + Remote(RpcError), + #[error("request cancelled")] + Cancelled, + #[error("request deadline exceeded")] + DeadlineExceeded, + #[error("endpoint unavailable")] + Unavailable, + #[error("session is closed")] + Closed, +} + +impl Error { + /// The closed RPC kind when the failure came from an accepted request. + /// Transport and local protocol failures have no remote application kind. + pub const fn rpc_kind(&self) -> Option { + match self { + Self::Remote(error) => Some(error.data.kind), + Self::Cancelled => Some(ErrorKind::Cancelled), + Self::DeadlineExceeded => Some(ErrorKind::DeadlineExceeded), + Self::Unavailable => Some(ErrorKind::Unavailable), + Self::Io(_) | Self::Protocol(_) | Self::ProtocolOwned(_) | Self::Closed => None, + } + } + + /// A non-secret local description suitable for an FFI error object. + pub const fn stable_message(&self) -> &'static str { + match self { + Self::Io(_) => "I/O error", + Self::Protocol(_) | Self::ProtocolOwned(_) => "protocol error", + Self::Remote(_) => "remote error", + Self::Cancelled => "cancelled", + Self::DeadlineExceeded => "deadline exceeded", + Self::Unavailable => "unavailable", + Self::Closed => "session closed", + } + } +} + +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rpc_kind_preserves_closed_remote_semantics() { + for kind in [ + ErrorKind::InteractionRequired, + ErrorKind::PermissionDenied, + ErrorKind::Conflict, + ErrorKind::Unavailable, + ] { + let error = if kind == ErrorKind::Unavailable { + Error::Unavailable + } else { + Error::Remote(RpcError::new(kind)) + }; + assert_eq!(error.rpc_kind(), Some(kind)); + assert_eq!(kind.to_string(), kind.message()); + assert!(!kind.as_str().contains(' ')); + } + assert_eq!(Error::Closed.rpc_kind(), None); + assert_eq!(Error::Protocol("bad frame").rpc_kind(), None); + } + + /// The rule that keeps the error set growable. A later revision naming a + /// kind this one has never heard of must be decodable, or the first + /// addition breaks every deployed peer and the set is frozen for the life + /// of the protocol version. + #[test] + fn an_undefined_kind_decodes_instead_of_failing_the_frame() { + let later: RpcError = serde_json::from_str( + r#"{"code":-32011,"message":"dynamic session required", + "data":{"kind":"dynamic_session_required","retryable":false}}"#, + ) + .unwrap(); + assert_eq!(later.data.kind, ErrorKind::Unrecognized); + later.validate().unwrap(); + // It is a failure, never a success, and never silently retryable. + assert!(!later.data.kind.retryable_by_default()); + + // A code this revision defines must still arrive with its own kind. A + // peer that disagrees there is broken, not merely newer. + let mismatched: RpcError = serde_json::from_str( + r#"{"code":-32005,"message":"permission denied", + "data":{"kind":"something_else","retryable":false}}"#, + ) + .unwrap(); + assert_eq!(mismatched.data.kind, ErrorKind::Unrecognized); + assert!(mismatched.validate().is_err()); + + // Every defined kind still round-trips through its wire spelling. + for kind in ErrorKind::DEFINED { + assert_eq!(ErrorKind::from_wire(kind.as_str()), *kind); + assert_eq!(ErrorKind::from_code(kind.code()), Some(*kind)); + } + assert_eq!(ErrorKind::from_code(-32011), None); + } + + #[test] + fn retry_after_must_be_positive() { + assert!(RpcError::unavailable(Some(1)).validate().is_ok()); + assert!(RpcError::unavailable(Some(0)).validate().is_err()); + } + + #[test] + fn interaction_references_are_bounded_and_kind_specific() { + let reference = InteractionReference::authorization("apr_7K3M", Some(1)); + let error = RpcError::interaction_required(Some(reference.clone())); + error.validate().unwrap(); + let encoded = serde_json::to_string(&error).unwrap(); + let decoded: RpcError = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.data.interaction, Some(reference)); + + let mut wrong_kind = RpcError::new(ErrorKind::PermissionDenied); + wrong_kind.data.interaction = Some(InteractionReference::authorization("apr_1", None)); + assert!(wrong_kind.validate().is_err()); + + assert!( + RpcError::interaction_required(Some(InteractionReference::authorization( + "terminal escape \u{1b}", + None, + ))) + .validate() + .is_err() + ); + } + + #[test] + fn later_interaction_kinds_decode_without_becoming_authority() { + let error: RpcError = serde_json::from_str( + r#"{"code":-32006,"message":"interaction required", + "data":{"kind":"interaction_required","retryable":false, + "interaction":{"kind":"later_kind","id":"ref_1", + "expires_at_unix_ms":null}}}"#, + ) + .unwrap(); + assert_eq!( + error.data.interaction.as_ref().unwrap().kind, + InteractionKind::Unrecognized + ); + error.validate().unwrap(); + } +} diff --git a/secretspec-ipc/src/frame.rs b/secretspec-ipc/src/frame.rs new file mode 100644 index 000000000..bb17bcb28 --- /dev/null +++ b/secretspec-ipc/src/frame.rs @@ -0,0 +1,249 @@ +use crate::ABSOLUTE_MAX_FRAME_BYTES; +use crate::error::{Error, Result}; +use zeroize::Zeroizing; + +/// Incremental bounded NDJSON decoder. A frame is one non-empty UTF-8 JSON +/// object followed by LF; the limit applies to JSON bytes, not the delimiter. +#[derive(Debug)] +pub struct FrameDecoder { + limit: usize, + payload: Zeroizing>, +} + +impl FrameDecoder { + pub fn new(limit: usize) -> Result { + validate_limit(limit)?; + Ok(Self { + limit, + payload: Zeroizing::new(Vec::new()), + }) + } + + pub const fn limit(&self) -> usize { + self.limit + } + + pub fn set_limit(&mut self, limit: usize) -> Result<()> { + validate_limit(limit)?; + if !self.payload.is_empty() { + return Err(Error::Protocol("cannot change a frame limit mid-frame")); + } + self.limit = limit; + Ok(()) + } + + pub fn push(&mut self, bytes: &[u8]) -> Result>>> { + let mut frames = Vec::new(); + for &byte in bytes { + if byte == b'\n' { + if self.payload.is_empty() { + return Err(Error::Protocol("zero-length frame")); + } + std::str::from_utf8(&self.payload) + .map_err(|_| Error::Protocol("frame payload is not valid UTF-8"))?; + frames.push(std::mem::replace( + &mut self.payload, + Zeroizing::new(Vec::new()), + )); + } else { + if self.payload.len() >= self.limit { + return Err(Error::Protocol("frame exceeds the active limit")); + } + self.payload.push(byte); + } + } + Ok(frames) + } + + pub fn finish_eof(&self) -> Result<()> { + if self.payload.is_empty() { + Ok(()) + } else { + Err(Error::Protocol("truncated frame")) + } + } +} + +pub fn encode(payload: &[u8], limit: usize) -> Result> { + validate_payload(payload, limit)?; + let mut frame = Vec::with_capacity(payload.len() + 1); + frame.extend_from_slice(payload); + frame.push(b'\n'); + Ok(frame) +} + +fn validate_limit(limit: usize) -> Result<()> { + if limit == 0 || limit > ABSOLUTE_MAX_FRAME_BYTES { + Err(Error::Protocol("frame limit is outside the absolute bound")) + } else { + Ok(()) + } +} + +fn validate_payload(payload: &[u8], limit: usize) -> Result<()> { + validate_limit(limit)?; + if payload.is_empty() { + return Err(Error::Protocol("zero-length frame")); + } + if payload.len() > limit { + return Err(Error::Protocol("frame exceeds the active limit")); + } + if payload.contains(&b'\n') || payload.contains(&b'\r') { + return Err(Error::Protocol("frame payload must be single-line JSON")); + } + std::str::from_utf8(payload) + .map_err(|_| Error::Protocol("frame payload is not valid UTF-8"))?; + Ok(()) +} + +#[cfg(feature = "tokio")] +pub(crate) struct AsyncFrameReader { + reader: tokio::io::BufReader, +} + +#[cfg(feature = "tokio")] +impl AsyncFrameReader +where + R: tokio::io::AsyncRead + Unpin, +{ + pub(crate) fn new(reader: R) -> Self { + Self { + reader: tokio::io::BufReader::new(reader), + } + } + + pub(crate) async fn read_frame(&mut self, limit: usize) -> Result>>> { + use tokio::io::AsyncBufReadExt; + let mut decoder = FrameDecoder::new(limit)?; + loop { + let (consumed, mut frames) = { + let available = self.reader.fill_buf().await?; + if available.is_empty() { + decoder.finish_eof()?; + return Ok(None); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |position| position + 1); + (consumed, decoder.push(&available[..consumed])?) + }; + self.reader.consume(consumed); + if let Some(frame) = frames.pop() { + debug_assert!(frames.is_empty()); + return Ok(Some(frame)); + } + } + } +} + +/// Read one frame from a one-shot or already-buffered stream. +/// +/// Long-lived protocol loops use `AsyncFrameReader` so buffered bytes after +/// the delimiter are retained for the next call. This compatibility helper +/// intentionally avoids reading past the delimiter because it cannot retain +/// state owned by a raw `AsyncRead` caller. +#[cfg(feature = "tokio")] +pub async fn read_frame(reader: &mut R, limit: usize) -> Result>>> +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + let mut decoder = FrameDecoder::new(limit)?; + let mut byte = [0_u8; 1]; + loop { + match reader.read(&mut byte).await? { + 0 => { + decoder.finish_eof()?; + return Ok(None); + } + _ => { + let mut frames = decoder.push(&byte)?; + if let Some(frame) = frames.pop() { + return Ok(Some(frame)); + } + } + } + } +} + +#[cfg(feature = "tokio")] +pub async fn write_frame(writer: &mut W, payload: &[u8], limit: usize) -> Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::AsyncWriteExt; + let frame = Zeroizing::new(encode(payload, limit)?); + writer.write_all(&frame).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn accepts_every_chunk_boundary_and_multiple_frames() { + let all = [ + encode(br#"{\"a\":1}"#, 1024).unwrap(), + encode(br#"{\"b\":2}"#, 1024).unwrap(), + ] + .concat(); + let mut decoder = FrameDecoder::new(1024).unwrap(); + let mut decoded = Vec::new(); + for byte in all { + decoded.extend(decoder.push(&[byte]).unwrap()); + } + decoder.finish_eof().unwrap(); + assert_eq!(decoded.len(), 2); + } + #[test] + fn rejects_a_missing_delimiter_at_the_bound() { + let mut decoder = FrameDecoder::new(4).unwrap(); + assert!(decoder.push(b"12345").is_err()); + } + + #[cfg(feature = "tokio")] + #[tokio::test] + async fn async_reader_reuses_one_buffered_chunk_across_frames() { + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + use tokio::io::{AsyncRead, ReadBuf}; + + struct CountingReader { + bytes: Vec, + position: usize, + reads: Arc, + } + + impl AsyncRead for CountingReader { + fn poll_read( + mut self: Pin<&mut Self>, + _context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + self.reads.fetch_add(1, Ordering::Relaxed); + let available = &self.bytes[self.position..]; + let read = available.len().min(buffer.remaining()); + buffer.put_slice(&available[..read]); + self.position += read; + Poll::Ready(Ok(())) + } + } + + let reads = Arc::new(AtomicUsize::new(0)); + let source = CountingReader { + bytes: b"{\"a\":1}\n{\"b\":2}\n".to_vec(), + position: 0, + reads: Arc::clone(&reads), + }; + let mut reader = AsyncFrameReader::new(source); + let first = reader.read_frame(1024).await.unwrap().unwrap(); + let second = reader.read_frame(1024).await.unwrap().unwrap(); + assert_eq!(&*first, b"{\"a\":1}"); + assert_eq!(&*second, b"{\"b\":2}"); + assert_eq!(reads.load(Ordering::Relaxed), 1); + } +} diff --git a/secretspec-ipc/src/jsonrpc.rs b/secretspec-ipc/src/jsonrpc.rs new file mode 100644 index 000000000..4c9998060 --- /dev/null +++ b/secretspec-ipc/src/jsonrpc.rs @@ -0,0 +1,541 @@ +use crate::MAX_REQUEST_ID; +use crate::error::{Error, ErrorKind, Result, RpcError}; +use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use std::collections::HashSet; +use std::fmt; + +/// A positive, session-unique JSON request ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RequestId(u64); + +impl RequestId { + pub fn new(value: u64) -> Result { + if value == 0 || value > MAX_REQUEST_ID { + return Err(Error::Protocol("request ID is outside the version 1 range")); + } + Ok(Self(value)) + } + + pub const fn get(self) -> u64 { + self.0 + } +} + +impl Serialize for RequestId { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_u64(self.0) + } +} + +impl<'de> Deserialize<'de> for RequestId { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = u64::deserialize(deserializer)?; + if value == 0 || value > MAX_REQUEST_ID { + return Err(D::Error::custom("invalid version 1 request ID")); + } + Ok(Self(value)) + } +} + +/// The JSON-RPC version literal. A dedicated type prevents accepting another +/// string through otherwise valid derived structures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Version; + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + if value == "2.0" { + Ok(Self) + } else { + Err(D::Error::custom("jsonrpc must be \"2.0\"")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Meta { + /// Mandatory absolute end-to-end deadline. Generic RPC metadata lives + /// under this one reserved member instead of expanding the envelope. + pub deadline_unix_ms: u64, + /// A callback names the still-active request that caused it. Ordinary + /// client-to-server requests omit this member. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub jsonrpc: Version, + pub id: RequestId, + pub method: String, + #[serde(rename = "_meta")] + pub meta: Meta, + pub params: Value, +} + +impl Request { + pub fn new( + id: RequestId, + method: impl Into, + deadline_unix_ms: u64, + params: Value, + ) -> Result { + let method = method.into(); + validate_method_and_params(&method, ¶ms)?; + Ok(Self { + jsonrpc: Version, + id, + method, + meta: Meta { + deadline_unix_ms, + parent_request_id: None, + }, + params, + }) + } + + pub const fn deadline_unix_ms(&self) -> u64 { + self.meta.deadline_unix_ms + } + + pub fn with_parent_request_id(mut self, parent_request_id: RequestId) -> Self { + self.meta.parent_request_id = Some(parent_request_id); + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Notification { + pub jsonrpc: Version, + pub method: String, + pub params: Value, +} + +impl Notification { + pub fn new(method: impl Into, params: Value) -> Result { + let method = method.into(); + validate_method_and_params(&method, ¶ms)?; + Ok(Self { + jsonrpc: Version, + method, + params, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SuccessResponse { + pub jsonrpc: Version, + pub id: RequestId, + pub result: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ErrorResponse { + pub jsonrpc: Version, + #[serde(deserialize_with = "crate::protocol::deserialize_required_nullable")] + pub id: Option, + pub error: RpcError, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Response { + Success(SuccessResponse), + Error(ErrorResponse), +} + +impl Response { + pub fn success(id: RequestId, result: Value) -> Self { + Self::Success(SuccessResponse { + jsonrpc: Version, + id, + result, + }) + } + + pub fn error(id: Option, error: RpcError) -> Self { + Self::Error(ErrorResponse { + jsonrpc: Version, + id, + error, + }) + } + + pub const fn id(&self) -> Option { + match self { + Self::Success(response) => Some(response.id), + Self::Error(response) => response.id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Envelope { + Request(Request), + Notification(Notification), + Response(Response), +} + +impl Envelope { + /// Parse one strict JSON-RPC object, rejecting duplicate keys, non-objects, + /// unknown envelope members, invalid IDs, and excessive nesting. + pub fn parse(bytes: &[u8]) -> Result { + Self::parse_classified(bytes).map_err(|(error, _)| error) + } + + /// Parse, reporting the error kind the frame should be answered with. + /// + /// Malformed bytes yield `parse_error`; anything that parsed as JSON but + /// broke a rule above it (duplicate keys, nesting depth, unknown members, + /// invalid IDs, method and params shape) yields `invalid_request`. Callers + /// that must answer with an error kind use this instead of re-parsing the + /// frame with a laxer parser to guess which layer failed. + pub fn parse_classified(bytes: &[u8]) -> std::result::Result { + let value = parse_strict_value(bytes)?; + Self::from_strict_value(value).map_err(|error| (error, ErrorKind::InvalidRequest)) + } + + fn from_strict_value(value: Value) -> Result { + let object = value + .as_object() + .ok_or(Error::Protocol("JSON-RPC payload is not an object"))?; + + let envelope = if object.contains_key("method") && object.contains_key("id") { + Self::Request(from_value(value)?) + } else if object.contains_key("method") { + Self::Notification(from_value(value)?) + } else if object.contains_key("result") || object.contains_key("error") { + let response: Response = from_value(value)?; + if let Response::Error(error) = &response { + error.error.validate().map_err(Error::Protocol)?; + } + Self::Response(response) + } else { + return Err(Error::Protocol("unrecognized JSON-RPC envelope")); + }; + + match &envelope { + Self::Request(request) => { + validate_method_and_params(&request.method, &request.params)?; + } + Self::Notification(notification) => { + validate_method_and_params(¬ification.method, ¬ification.params)?; + } + Self::Response(_) => {} + } + Ok(envelope) + } + + pub fn to_vec(&self) -> Result> { + serde_json::to_vec(self).map_err(|error| Error::ProtocolOwned(error.to_string())) + } +} + +/// Borrows rather than consuming: parsing validates an already-built envelope, +/// and cloning `params` there duplicated the whole tree (up to a full frame, +/// including any secret it carries) only to be dropped again. +fn validate_method_and_params(method: &str, params: &Value) -> Result<()> { + if method.is_empty() || method.len() > 256 { + return Err(Error::Protocol("method has an invalid byte length")); + } + if !params.is_object() { + return Err(Error::Protocol("params must be an object")); + } + Ok(()) +} + +fn from_value Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value).map_err(|error| Error::ProtocolOwned(error.to_string())) +} + +fn parse_strict_value(bytes: &[u8]) -> std::result::Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValueSeed { depth: 0 } + .deserialize(&mut deserializer) + .map_err(classify_json_error)?; + deserializer.end().map_err(classify_json_error)?; + Ok(value) +} + +/// Separates "this was not JSON" from "this was JSON we refuse". +/// +/// `Syntax`/`Eof` mean the bytes are malformed, which is `parse_error`. `Data` +/// means the document parsed and one of the strict reader's own rules rejected +/// it (a duplicate key, or nesting past the limit); the peer sent well-formed +/// JSON, so that stays `invalid_request`. Reading the category off the existing +/// failure avoids re-parsing the frame just to tell the two apart. +fn classify_json_error(error: serde_json::Error) -> (Error, ErrorKind) { + let kind = match error.classify() { + serde_json::error::Category::Data => ErrorKind::InvalidRequest, + _ => ErrorKind::ParseError, + }; + (Error::ProtocolOwned(error.to_string()), kind) +} + +const MAX_NESTING: usize = 64; + +struct StrictValueSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictValueSeed { + type Value = Value; + + fn deserialize(self, deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(StrictValueVisitor { depth: self.depth }) + } +} + +struct StrictValueVisitor { + depth: usize, +} + +impl StrictValueVisitor { + fn child(&self) -> std::result::Result { + if self.depth >= MAX_NESTING { + Err(E::custom("JSON nesting exceeds 64 containers")) + } else { + Ok(StrictValueSeed { + depth: self.depth + 1, + }) + } + } +} + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an RFC 8259 JSON value without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> std::result::Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> std::result::Result { + Ok(Value::Number(value.into())) + } + + fn visit_u64(self, value: u64) -> std::result::Result { + Ok(Value::Number(value.into())) + } + + fn visit_f64(self, value: f64) -> std::result::Result + where + E: serde::de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> std::result::Result { + Ok(Value::String(value.to_string())) + } + + fn visit_string(self, value: String) -> std::result::Result { + Ok(Value::String(value)) + } + + fn visit_none(self) -> std::result::Result { + Ok(Value::Null) + } + + fn visit_unit(self) -> std::result::Result { + Ok(Value::Null) + } + + fn visit_some(self, deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + StrictValueSeed { depth: self.depth }.deserialize(deserializer) + } + + fn visit_seq(self, mut sequence: A) -> std::result::Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(self.child()?)? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: MapAccess<'de>, + { + let mut values = serde_json::Map::new(); + let mut keys = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(A::Error::custom("duplicate JSON object key")); + } + let value = map.next_value_seed(self.child()?)?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejection_reports_the_layer_that_refused_the_frame() { + // Only malformed bytes are `parse_error`. Everything that parsed as + // JSON is `invalid_request`, however far up it was rejected. Pinned + // because this is peer-visible and the classification is now read off + // the parse failure rather than recovered by re-parsing the frame. + for payload in [ + br#"{"jsonrpc":"2.0",}"#.as_slice(), + br#"{"jsonrpc":"2.0""#.as_slice(), + b"\xff\xfe".as_slice(), + ] { + let (_, kind) = Envelope::parse_classified(payload).unwrap_err(); + assert_eq!(kind, ErrorKind::ParseError, "{payload:?}"); + } + + let deep = format!("{}{}", "[".repeat(80), "]".repeat(80)); + for payload in [ + // Well-formed JSON the strict reader refuses on its own rules. + br#"{"jsonrpc":"2.0","jsonrpc":"2.0"}"#.as_slice(), + deep.as_bytes(), + // A batch array parses as JSON; JSON-RPC 2.0 answers an array with + // invalid_request rather than parse_error. + br#"[]"#.as_slice(), + // Envelope-layer rejections. + br#"{"jsonrpc":"2.0","id":0,"method":"x","_meta":{"deadline_unix_ms":1},"params":{}}"# + .as_slice(), + br#"{"jsonrpc":"2.0","id":1,"method":"","_meta":{"deadline_unix_ms":1},"params":{}}"# + .as_slice(), + br#"{"jsonrpc":"2.0","id":1,"method":"x","_meta":{"deadline_unix_ms":1},"params":[]}"# + .as_slice(), + br#"{"jsonrpc":"2.0"}"#.as_slice(), + ] { + let (_, kind) = Envelope::parse_classified(payload).unwrap_err(); + assert_eq!(kind, ErrorKind::InvalidRequest, "{payload:?}"); + } + } + + #[test] + fn rejects_duplicate_keys_and_batches() { + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","jsonrpc":"2.0","id":1,"method":"x","_meta":{"deadline_unix_ms":1},"params":{}}"# + ) + .is_err() + ); + assert!(Envelope::parse(br#"[]"#).is_err()); + } + + #[test] + fn request_ids_are_bounded_integers() { + for id in ["0", "-1", "1.5", "\"1\"", "9007199254740992"] { + let input = format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"x","_meta":{{"deadline_unix_ms":1}},"params":{{}}}}"# + ); + assert!(Envelope::parse(input.as_bytes()).is_err(), "accepted {id}"); + } + } + + #[test] + fn rejects_unknown_top_level_members() { + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","id":1,"method":"x","_meta":{"deadline_unix_ms":1},"params":{},"extra":true}"# + ) + .is_err() + ); + assert!(Envelope::parse(br#"{"jsonrpc":"2.0","id":1,"result":{},"extra":true}"#).is_err()); + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-32603,"message":"internal error","data":{"kind":"internal","retryable":false}}}"# + ) + .is_err() + ); + } + + #[test] + fn notifications_are_closed_envelopes_with_open_methods() { + for payload in [ + br#"{"jsonrpc":"2.0","method":"future.notice","params":{}}"#.as_slice(), + br#"{"jsonrpc":"2.0","method":"rpc.cancel","params":{"id":"bad"}}"#.as_slice(), + ] { + assert!(matches!( + Envelope::parse(payload), + Ok(Envelope::Notification(_)) + )); + } + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","method":"future.notice","params":{},"extra":true}"# + ) + .is_err() + ); + } + + #[test] + fn request_deadline_is_required_and_round_trips() { + assert!(Envelope::parse(br#"{"jsonrpc":"2.0","id":1,"method":"x","params":{}}"#).is_err()); + let parsed = Envelope::parse( + br#"{"jsonrpc":"2.0","id":1,"method":"x","_meta":{"deadline_unix_ms":42},"params":{}}"#, + ) + .unwrap(); + let Envelope::Request(request) = parsed else { + panic!("expected request") + }; + assert_eq!(request.deadline_unix_ms(), 42); + } + + #[test] + fn error_response_requires_an_explicit_nullable_id() { + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error","data":{"kind":"parse_error","retryable":false}}}"# + ) + .is_err() + ); + assert!( + Envelope::parse( + br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error","data":{"kind":"parse_error","retryable":false}}}"# + ) + .is_ok() + ); + } +} diff --git a/secretspec-ipc/src/launch.rs b/secretspec-ipc/src/launch.rs new file mode 100644 index 000000000..b5d5ec5d6 --- /dev/null +++ b/secretspec-ipc/src/launch.rs @@ -0,0 +1,77 @@ +//! Child-process launch configuration shared by every transport. +//! +//! Version 1 endpoints are directly launched children, so both the async +//! [`crate::lifecycle`] transport and the synchronous [`crate::blocking`] one +//! need the same executable, argument, environment, and capture rules. Keeping +//! them here means a caller can move between transports without rewriting its +//! launch configuration, and the trust rules below are stated once. + +use crate::{Error, Result}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub enum Environment { + /// Inherit the caller environment and apply these overrides. + Inherit(BTreeMap), + /// Clear the environment and install exactly these entries. + Replace(BTreeMap), +} + +#[derive(Debug, Clone)] +pub struct LaunchOptions { + pub executable: PathBuf, + pub arguments: Vec, + pub environment: Environment, + pub allow_path_discovery: bool, + pub max_stderr_bytes: usize, +} + +impl LaunchOptions { + pub fn validate(&self) -> Result<()> { + if self.executable.as_os_str().is_empty() { + return Err(Error::Protocol("executable is empty")); + } + if !self.allow_path_discovery && !self.executable.is_absolute() { + return Err(Error::Protocol( + "executable must be absolute unless discovery is enabled", + )); + } + if self.max_stderr_bytes > 1_048_576 { + return Err(Error::Protocol( + "stderr capture exceeds the version 1 bound", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(executable: &str, allow_path_discovery: bool) -> LaunchOptions { + LaunchOptions { + executable: PathBuf::from(executable), + arguments: Vec::new(), + environment: Environment::Inherit(BTreeMap::new()), + allow_path_discovery, + max_stderr_bytes: 4096, + } + } + + #[test] + fn relative_executables_require_explicit_discovery() { + assert!(options("secretspec", false).validate().is_err()); + assert!(options("secretspec", true).validate().is_ok()); + assert!(options("", true).validate().is_err()); + } + + #[test] + fn stderr_capture_is_bounded() { + let mut options = options("secretspec", true); + options.max_stderr_bytes = 1_048_577; + assert!(options.validate().is_err()); + } +} diff --git a/secretspec-ipc/src/lib.rs b/secretspec-ipc/src/lib.rs new file mode 100644 index 000000000..65b50c06a --- /dev/null +++ b/secretspec-ipc/src/lib.rs @@ -0,0 +1,51 @@ +//! SecretSpec IPC version 1. +//! +//! The checked-in JSON schemas and protocol documents are canonical. This +//! crate is an independent Rust implementation of their wire, client, server, +//! resolution, and provider state machines. + +pub mod deadline; +pub mod error; +pub mod frame; +pub mod jsonrpc; +pub mod launch; +pub mod protocol; + +#[cfg(feature = "tokio")] +mod description; + +#[cfg(feature = "blocking")] +pub mod blocking; + +#[cfg(feature = "tokio")] +pub mod client; +#[cfg(feature = "tokio")] +pub mod lifecycle; +#[cfg(feature = "tokio")] +pub mod provider; +#[cfg(feature = "tokio")] +pub mod resolver; +#[cfg(feature = "tokio")] +pub mod server; + +pub use deadline::unix_ms_after as deadline_unix_ms_after; +pub use error::{ + Error, ErrorData, ErrorKind, InteractionKind, InteractionReference, Result, RpcError, +}; +pub use jsonrpc::{Envelope, Notification, Request, RequestId, Response}; +pub use protocol::{Limits, Product}; + +/// Wire protocol major version implemented by this crate. +pub const WIRE_VERSION: u32 = 1; + +/// Absolute pre-negotiation and version 1 frame ceiling. +pub const ABSOLUTE_MAX_FRAME_BYTES: usize = 1_048_576; + +/// Smallest negotiable frame limit. +pub const MIN_FRAME_BYTES: usize = 4_096; + +/// Version 1 in-flight ceiling. +pub const MAX_IN_FLIGHT: usize = 32; + +/// Largest request ID that is exactly representable by JSON/JavaScript peers. +pub const MAX_REQUEST_ID: u64 = 9_007_199_254_740_991; diff --git a/secretspec-ipc/src/lifecycle.rs b/secretspec-ipc/src/lifecycle.rs new file mode 100644 index 000000000..dc1e9827e --- /dev/null +++ b/secretspec-ipc/src/lifecycle.rs @@ -0,0 +1,826 @@ +use crate::client::{CallbackHandler, Client}; +use crate::deadline::instant_from_unix_ms; +use crate::error::{ErrorKind, RpcError}; +use crate::protocol::callback; +use crate::protocol::provider::{ + self as provider_protocol, InitializeApplication as ProviderInitializeApplication, + InitializedApplication as ProviderInitializedApplication, Metadata, +}; +use crate::protocol::resolver::{ + self as resolver_protocol, InitializeApplication as ResolverInitializeApplication, + InitializedApplication as ResolverInitializedApplication, +}; +use crate::protocol::{ + InitializeParams, InitializeResult, Limits, PROTOCOL_VERSION, PROVIDER_PROTOCOL, Product, + RESOLVER_PROTOCOL, +}; +use crate::{Error, Result, deadline_unix_ms_after}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::collections::HashSet; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +/// Budget for reaping a child that had to be killed, and for draining its +/// stderr afterwards. Both are bounded local operations, so they are measured +/// from the moment they start rather than from the caller's shutdown deadline +/// (which the graceful wait has already spent by then). +const REAP_GRACE: Duration = Duration::from_secs(2); + +pub use crate::launch::{Environment, LaunchOptions}; + +/// An owned child and its initialized wire session. +pub struct ChildSession { + client: Client, + child: Arc>, + monitor_cancel: CancellationToken, + monitor: Mutex>>, + stderr: Mutex>>>>, + closed: AtomicBool, +} + +impl ChildSession { + pub fn client(&self) -> &Client { + &self.client + } + + pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> { + if self.closed.swap(true, Ordering::AcqRel) { + return Ok(()); + } + let protocol_outcome = self.client.close(deadline_unix_ms).await; + self.monitor_cancel.cancel(); + if let Some(monitor) = self.monitor.lock().await.take() { + let _ = monitor.await; + } + + let requested = instant_from_unix_ms(deadline_unix_ms); + let cap = Instant::now() + Duration::from_secs(5); + let deadline = requested.min(cap); + let wait_outcome = wait_until(&self.child, deadline).await; + let mut kill_error = None; + if !matches!(&wait_outcome, Ok(true)) { + // The graceful wait above runs until `deadline` elapses, so reusing + // it here would leave the kill no budget at all and the child would + // never be reaped. Reaping a killed child is bounded work, so it + // gets its own small budget measured from now. + let kill_deadline = Instant::now() + REAP_GRACE; + let mut child = self.child.lock().await; + if let Err(error) = child.start_kill() { + kill_error = Some(error); + } else { + let _ = tokio::time::timeout_at(kill_deadline, child.wait()).await; + } + } + if let Some(stderr) = self.stderr.lock().await.take() { + // Likewise measured from now: `deadline` is already spent whenever + // the child needed killing, and draining a closed pipe is bounded. + finish_stderr(stderr, Instant::now() + REAP_GRACE).await; + } + wait_outcome?; + if let Some(error) = kill_error { + return Err(Error::Io(error)); + } + protocol_outcome + } +} + +/// An initialized `secretspec.provider/1` client together with the child +/// process that owns its private transport. +pub struct ProviderSession { + child: ChildSession, + capabilities: HashSet, + metadata: Metadata, +} + +/// Resolves provider authentication material requested over the private IPC +/// session (0.20+). +/// +/// Implementations must namespace the request by the already selected provider +/// principal, must not log returned values, and should avoid consulting a +/// general project provider that could recurse back into the endpoint being +/// initialized. +#[async_trait::async_trait] +pub trait CredentialResponder: Send + Sync + 'static { + async fn credential( + &self, + params: callback::CredentialParams, + ) -> std::result::Result; +} + +struct CredentialCallbacks { + responder: Arc, +} + +#[async_trait::async_trait] +impl CallbackHandler for CredentialCallbacks { + async fn call( + &self, + method: &str, + params: serde_json::Value, + ) -> std::result::Result { + if method != callback::method::CREDENTIAL { + return Err(RpcError::new(ErrorKind::MethodNotFound)); + } + let params: callback::CredentialParams = + serde_json::from_value(params).map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + params + .validate() + .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + let result = self.responder.credential(params).await?; + result + .validate() + .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } +} + +macro_rules! provider_calls { + ($(($name:ident, $method:ty)),+ $(,)?) => { + $( + pub async fn $name( + &self, + params: &<$method as provider_protocol::method::Method>::Params, + deadline_unix_ms: u64, + ) -> Result<<$method as provider_protocol::method::Method>::Result> { + self.execute::<$method>(params, deadline_unix_ms).await + } + )+ + }; +} + +impl ProviderSession { + /// Launch and validate a provider endpoint. The returned session owns both + /// process and transport. + pub async fn launch( + options: LaunchOptions, + client: Product, + limits: Limits, + application: ProviderInitializeApplication, + startup_deadline_unix_ms: u64, + ) -> Result { + Self::launch_with_credential_broker( + options, + client, + limits, + application, + startup_deadline_unix_ms, + None, + ) + .await + } + + /// As [`Self::launch`], allowing the endpoint to request only the provider + /// credentials it actually needs while initialization is pending (0.20+). + pub async fn launch_with_credential_broker( + options: LaunchOptions, + client: Product, + limits: Limits, + application: ProviderInitializeApplication, + startup_deadline_unix_ms: u64, + responder: Option>, + ) -> Result { + application.validate()?; + let expected_scheme = application.scheme.clone(); + let (client_methods, callbacks): (Vec, Option>) = + match responder { + Some(responder) => ( + vec![callback::method::CREDENTIAL.to_string()], + Some(Arc::new(CredentialCallbacks { responder })), + ), + None => (Vec::new(), None), + }; + let initialize = InitializeParams { + protocol: PROVIDER_PROTOCOL.to_string(), + versions: vec![PROTOCOL_VERSION], + client, + limits, + client_methods, + application, + }; + let (child, initialized) = spawn_with_callbacks::<_, ProviderInitializedApplication>( + options, + initialize, + startup_deadline_unix_ms, + callbacks, + ) + .await?; + let validation = initialized + .application + .provider + .validate() + .and_then(|_| provider_protocol::validate_capabilities(&initialized.methods)) + .and_then(|_| { + if initialized.application.provider.name == expected_scheme { + Ok(()) + } else { + Err(Error::Protocol( + "provider metadata name does not match its scheme", + )) + } + }); + if let Err(error) = validation { + let _ = child + .close(deadline_unix_ms_after(Duration::from_secs(1))) + .await; + return Err(error); + } + Ok(Self { + child, + capabilities: initialized.methods.into_iter().collect(), + metadata: initialized.application.provider, + }) + } + + pub fn raw(&self) -> &Client { + self.child.client() + } + + pub async fn execute(&self, params: &M::Params, deadline_unix_ms: u64) -> Result + where + M: provider_protocol::method::Method, + { + self.call(M::NAME, params, deadline_unix_ms).await + } + + pub async fn call( + &self, + method: &str, + params: &P, + deadline_unix_ms: u64, + ) -> Result { + self.child + .client() + .call(method, params, deadline_unix_ms) + .await + } + + provider_calls!( + (resolve_address, provider_protocol::method::ResolveAddress), + (get, provider_protocol::method::Get), + (get_many, provider_protocol::method::GetMany), + (exists, provider_protocol::method::Exists), + (set, provider_protocol::method::Set), + (set_expiring, provider_protocol::method::SetExpiring), + (delete, provider_protocol::method::Delete), + (clear, provider_protocol::method::Clear), + ( + describe_write_target, + provider_protocol::method::DescribeWriteTarget + ), + (reflect, provider_protocol::method::Reflect), + ); + + pub async fn check_writable( + &self, + params: &provider_protocol::AddressParams, + deadline_unix_ms: u64, + ) -> Result<()> { + self.execute::(params, deadline_unix_ms) + .await + .map(|_| ()) + } + + pub async fn check_deletable( + &self, + params: &provider_protocol::AddressParams, + deadline_unix_ms: u64, + ) -> Result<()> { + self.execute::(params, deadline_unix_ms) + .await + .map(|_| ()) + } + + pub fn capabilities(&self) -> &HashSet { + &self.capabilities + } + + pub fn supports(&self, method: &str) -> bool { + self.capabilities.contains(method) + } + + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + pub fn is_closed(&self) -> bool { + self.child.client().is_closed() + } + + pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> { + self.child.close(deadline_unix_ms).await + } +} + +/// An initialized `secretspec.resolver/1` client together with the child process +/// that owns its private transport. +pub struct ResolverSession { + child: ChildSession, + capabilities: HashSet, + initialized: ResolverInitializedApplication, +} + +/// Obtains one secret value from a person on the resolver's behalf (0.20+). +/// +/// A resolver in stdio mode has no terminal: its stdin and stdout are the +/// protocol. When a declaration says `prompt = true` and no value is stored, +/// the process that can ask is the one that launched the resolver, so the +/// resolver asks it. A session that installs no responder advertises nothing, +/// and such a declaration fails immediately with `interaction_required` rather +/// than waiting out a deadline. +/// +/// The answer is a secret. Implementations must read it without echo, must not +/// log it, and should hand it straight back. +#[async_trait::async_trait] +pub trait PromptResponder: Send + Sync + 'static { + async fn prompt( + &self, + params: callback::PromptParams, + ) -> std::result::Result; +} + +struct PromptCallbacks { + responder: Arc, +} + +#[async_trait::async_trait] +impl CallbackHandler for PromptCallbacks { + async fn call( + &self, + method: &str, + params: serde_json::Value, + ) -> std::result::Result { + if method != callback::method::PROMPT { + return Err(RpcError::new(ErrorKind::MethodNotFound)); + } + let params: callback::PromptParams = + serde_json::from_value(params).map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + params + .validate() + .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + let result = self.responder.prompt(params).await?; + // Validated on the way out too: an answer this client would refuse to + // accept from a peer is one it must not send as a peer either. + result + .validate() + .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } +} + +impl ResolverSession { + pub async fn launch( + options: LaunchOptions, + client: Product, + limits: Limits, + application: ResolverInitializeApplication, + startup_deadline_unix_ms: u64, + ) -> Result { + Self::launch_with_prompt( + options, + client, + limits, + application, + startup_deadline_unix_ms, + None, + ) + .await + } + + /// As [`Self::launch`], letting the resolver ask this process for a value a + /// `prompt = true` declaration has no stored value for (0.20+). + pub async fn launch_with_prompt( + options: LaunchOptions, + client: Product, + limits: Limits, + application: ResolverInitializeApplication, + startup_deadline_unix_ms: u64, + responder: Option>, + ) -> Result { + application.validate()?; + let (client_methods, callbacks): (Vec, Option>) = + match responder { + Some(responder) => ( + vec![callback::method::PROMPT.to_string()], + Some(Arc::new(PromptCallbacks { responder })), + ), + None => (Vec::new(), None), + }; + let initialize = InitializeParams { + protocol: RESOLVER_PROTOCOL.to_string(), + versions: vec![PROTOCOL_VERSION], + client, + limits, + client_methods, + application, + }; + let (child, initialized) = spawn_with_callbacks::<_, ResolverInitializedApplication>( + options, + initialize, + startup_deadline_unix_ms, + callbacks, + ) + .await?; + if let Err(error) = initialized.application.validate() { + let _ = child + .close(deadline_unix_ms_after(Duration::from_secs(1))) + .await; + return Err(error); + } + let capabilities: HashSet<_> = initialized.methods.into_iter().collect(); + if !resolver_protocol::CAPABILITIES + .iter() + .all(|method| capabilities.contains(*method)) + { + let _ = child + .close(deadline_unix_ms_after(Duration::from_secs(1))) + .await; + return Err(Error::Protocol( + "resolution endpoint did not advertise all required methods", + )); + } + Ok(Self { + child, + capabilities, + initialized: initialized.application, + }) + } + + pub fn raw(&self) -> &Client { + self.child.client() + } + + pub async fn get( + &self, + params: &resolver_protocol::GetParams, + deadline_unix_ms: u64, + ) -> Result { + self.child + .client() + .call(resolver_protocol::method::GET, params, deadline_unix_ms) + .await + } + + pub async fn release( + &self, + params: &resolver_protocol::ReleaseParams, + deadline_unix_ms: u64, + ) -> Result { + self.child + .client() + .call(resolver_protocol::method::RELEASE, params, deadline_unix_ms) + .await + } + + /// Store one declared name (0.20+). Only endpoints that advertise + /// `resolver.set` accept it, so check [`Self::supports`] first when the + /// caller can explain a read-only endpoint better than the wire error does. + pub async fn set( + &self, + params: &resolver_protocol::SetParams, + deadline_unix_ms: u64, + ) -> Result { + self.child + .client() + .call(resolver_protocol::method::SET, params, deadline_unix_ms) + .await + } + + /// Remove one declared name's stored value (0.20+), advertised as + /// `resolver.delete` under the same rule as [`Self::set`]. + pub async fn delete( + &self, + params: &resolver_protocol::DeleteParams, + deadline_unix_ms: u64, + ) -> Result { + self.child + .client() + .call(resolver_protocol::method::DELETE, params, deadline_unix_ms) + .await + } + + pub fn capabilities(&self) -> &HashSet { + &self.capabilities + } + + /// Whether the endpoint advertised one method, such as + /// [`resolver_protocol::method::SET`]. + pub fn supports(&self, method: &str) -> bool { + self.capabilities.contains(method) + } + + pub fn initialized(&self) -> &ResolverInitializedApplication { + &self.initialized + } + + pub fn is_closed(&self) -> bool { + self.child.client().is_closed() + } + + pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> { + self.child.close(deadline_unix_ms).await + } +} + +impl Drop for ChildSession { + fn drop(&mut self) { + // Emergency best effort only; the explicit async close path reaps and + // joins every worker. Never block a foreign/runtime destructor. + self.monitor_cancel.cancel(); + if let Ok(mut child) = self.child.try_lock() { + let _ = child.start_kill(); + } + } +} + +pub async fn spawn( + options: LaunchOptions, + initialize: InitializeParams, + startup_deadline_unix_ms: u64, +) -> Result<(ChildSession, InitializeResult)> +where + A: Serialize, + B: DeserializeOwned, +{ + spawn_with_callbacks(options, initialize, startup_deadline_unix_ms, None).await +} + +/// As [`spawn`], installing the handler for the callbacks this client +/// advertised in `client_methods` (0.20+). +pub async fn spawn_with_callbacks( + options: LaunchOptions, + initialize: InitializeParams, + startup_deadline_unix_ms: u64, + callbacks: Option>, +) -> Result<(ChildSession, InitializeResult)> +where + A: Serialize, + B: DeserializeOwned, +{ + options.validate()?; + let mut command = Command::new(&options.executable); + command + .args(&options.arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(false); + match &options.environment { + Environment::Inherit(overrides) => { + command.envs(overrides); + } + Environment::Replace(environment) => { + command.env_clear().envs(environment); + } + } + + let mut child = command.spawn()?; + let stdin = child + .stdin + .take() + .ok_or(Error::Protocol("child stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or(Error::Protocol("child stdout was not piped"))?; + let mut stderr = child + .stderr + .take() + .ok_or(Error::Protocol("child stderr was not piped"))?; + + let stderr_limit = options.max_stderr_bytes; + let stderr_task = tokio::spawn(async move { + let mut retained = Zeroizing::new(Vec::with_capacity(stderr_limit.min(4096))); + let mut buffer = Zeroizing::new(vec![0_u8; 4096]); + loop { + let read: usize = stderr.read(&mut buffer).await.unwrap_or_default(); + if read == 0 { + break; + } + let available = stderr_limit.saturating_sub(retained.len()); + retained.extend_from_slice(&buffer[..read.min(available)]); + } + retained + }); + + let child = Arc::new(Mutex::new(child)); + let connect = Client::connect_with_callbacks( + stdout, + stdin, + initialize, + startup_deadline_unix_ms, + callbacks, + ) + .await; + let (client, initialized) = match connect { + Ok(value) => value, + Err(error) => { + let mut child = child.lock().await; + let _ = child.start_kill(); + // Initialization commonly fails because its deadline has already + // elapsed. Killing and reaping are bounded cleanup from this + // point, so they need a fresh budget rather than that spent + // startup deadline. + let _ = tokio::time::timeout(REAP_GRACE, child.wait()).await; + drop(child); + finish_stderr(stderr_task, Instant::now() + REAP_GRACE).await; + return Err(error); + } + }; + + let monitor_cancel = CancellationToken::new(); + let monitor_child = child.clone(); + let monitor_client = client.clone(); + let monitor_stop = monitor_cancel.clone(); + let monitor = tokio::spawn(async move { + loop { + tokio::select! { + _ = monitor_stop.cancelled() => break, + _ = tokio::time::sleep(Duration::from_millis(25)) => { + let exited = monitor_child + .lock() + .await + .try_wait() + .ok() + .flatten() + .is_some(); + if exited { + monitor_client + .abandon_after_process_exit(REAP_GRACE) + .await; + break; + } + } + } + } + }); + + Ok(( + ChildSession { + client, + child, + monitor_cancel, + monitor: Mutex::new(Some(monitor)), + stderr: Mutex::new(Some(stderr_task)), + closed: AtomicBool::new(false), + }, + initialized, + )) +} + +async fn wait_until(child: &Arc>, deadline: Instant) -> Result { + loop { + if child.lock().await.try_wait()?.is_some() { + return Ok(true); + } + if Instant::now() >= deadline { + return Ok(false); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn finish_stderr(mut task: JoinHandle>>, deadline: Instant) { + if tokio::time::timeout_at(deadline, &mut task).await.is_err() { + task.abort(); + let _ = task.await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::InitializeResult; + use crate::server::{ApplicationHandler, RequestContext, RpcResult, ServerConfig, serve}; + use async_trait::async_trait; + use serde_json::{Value, json}; + use tokio::sync::Notify; + + struct GatedShutdown { + entered: Arc, + release: Arc, + } + + #[async_trait] + impl ApplicationHandler for GatedShutdown { + fn protocol(&self) -> &'static str { + RESOLVER_PROTOCOL + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".to_string()] + } + + async fn initialize( + &self, + _context: &RequestContext, + _application: Value, + ) -> RpcResult { + Ok(json!({})) + } + + async fn call( + &self, + _context: RequestContext, + _method: &str, + _params: Value, + ) -> RpcResult { + Ok(json!({})) + } + + async fn shutdown(&self) { + self.entered.notify_one(); + self.release.notified().await; + } + } + + #[tokio::test] + async fn stderr_cleanup_is_bounded() { + let task = tokio::spawn(async { + std::future::pending::<()>().await; + Zeroizing::new(Vec::new()) + }); + tokio::time::timeout( + Duration::from_millis(250), + finish_stderr(task, Instant::now() + Duration::from_millis(20)), + ) + .await + .expect("stderr cleanup exceeded its deadline"); + } + + /// A process watcher can observe exit immediately after the endpoint + /// commits its shutdown response, before the reader consumes that buffered + /// frame. Start the simulated watcher while shutdown is gated (an even + /// stricter ordering), then repeat enough times to exercise scheduler + /// choices. + #[tokio::test] + async fn process_exit_waits_for_a_buffered_shutdown_response() { + for _ in 0..32 { + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let handler = Arc::new(GatedShutdown { + entered: entered.clone(), + release: release.clone(), + }); + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve( + server_read, + server_write, + handler, + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: RESOLVER_PROTOCOL.to_string(), + versions: vec![PROTOCOL_VERSION], + client: Product { + name: "exit-race-test".to_string(), + version: "1".to_string(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + }; + let (client, _): (Client, InitializeResult) = Client::connect( + client_read, + client_write, + initialize, + deadline_unix_ms_after(Duration::from_secs(2)), + ) + .await + .unwrap(); + + let close_client = client.clone(); + let close = tokio::spawn(async move { + close_client + .close(deadline_unix_ms_after(Duration::from_secs(2))) + .await + }); + entered.notified().await; + let monitor_client = client.clone(); + let monitor = tokio::spawn(async move { + monitor_client + .abandon_after_process_exit(Duration::from_secs(1)) + .await; + }); + release.notify_one(); + + close.await.unwrap().unwrap(); + monitor.await.unwrap(); + server.await.unwrap().unwrap(); + } + } +} diff --git a/secretspec-ipc/src/protocol.rs b/secretspec-ipc/src/protocol.rs new file mode 100644 index 000000000..805631ef8 --- /dev/null +++ b/secretspec-ipc/src/protocol.rs @@ -0,0 +1,1607 @@ +use crate::error::{Error, Result}; +use crate::{ABSOLUTE_MAX_FRAME_BYTES, MAX_IN_FLIGHT, MIN_FRAME_BYTES}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const RESOLVER_PROTOCOL: &str = "secretspec.resolver"; +pub const PROVIDER_PROTOCOL: &str = "secretspec.provider"; +pub const PROTOCOL_VERSION: u32 = 1; + +pub mod rpc { + /// Return this endpoint's OpenRPC description without initializing + /// application state (0.20+). + pub const DISCOVER: &str = "rpc.discover"; + pub const INITIALIZE: &str = "rpc.initialize"; + pub const CANCEL: &str = "rpc.cancel"; + pub const SHUTDOWN: &str = "rpc.shutdown"; + + pub const ALL: &[&str] = &[DISCOVER, INITIALIZE, CANCEL, SHUTDOWN]; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Product { + pub name: String, + pub version: String, +} + +impl Product { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("product name", &self.name, 256)?; + validate_nonempty_bytes("product version", &self.version, 256) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Limits { + pub max_frame_bytes: usize, + pub max_in_flight: usize, +} + +impl Limits { + pub const PRE_NEGOTIATION: Self = Self { + max_frame_bytes: ABSOLUTE_MAX_FRAME_BYTES, + max_in_flight: 1, + }; + + pub fn validate(self) -> Result<()> { + if !(MIN_FRAME_BYTES..=ABSOLUTE_MAX_FRAME_BYTES).contains(&self.max_frame_bytes) { + return Err(Error::Protocol( + "max_frame_bytes is outside the version 1 range", + )); + } + if !(1..=MAX_IN_FLIGHT).contains(&self.max_in_flight) { + return Err(Error::Protocol( + "max_in_flight is outside the version 1 range", + )); + } + Ok(()) + } + + pub fn select(self, peer: Self) -> Result { + self.validate()?; + peer.validate()?; + Ok(Self { + max_frame_bytes: self.max_frame_bytes.min(peer.max_frame_bytes), + max_in_flight: self.max_in_flight.min(peer.max_in_flight), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InitializeParams { + pub protocol: String, + pub versions: Vec, + pub client: Product, + pub limits: Limits, + /// Methods this client can answer when the server calls back on the same + /// session (0.20+). Empty, and omitted on the wire, for a client that + /// answers none, which is every client before this field existed. + /// + /// The server's `methods` say what a client may ask for. These say + /// what the server may ask of the client, and a server MUST NOT send a + /// method that is not listed here. A consumer with no way to reach a person + /// therefore advertises nothing and is told so immediately, rather than + /// waiting out a deadline on an interaction that was never going to arrive. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub client_methods: Vec, + pub application: A, +} + +impl InitializeParams { + pub fn validate_common(&self, expected_protocol: &str) -> Result<()> { + if self.protocol != expected_protocol { + return Err(Error::Protocol( + "initialization selected the wrong protocol", + )); + } + if self.versions.is_empty() + || self.versions.contains(&0) + || self.versions.iter().collect::>().len() != self.versions.len() + { + return Err(Error::Protocol( + "versions must be distinct positive integers", + )); + } + self.client.validate()?; + self.limits.validate()?; + validate_capabilities(&self.client_methods)?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InitializeResult { + pub protocol: String, + pub version: u32, + pub server: Product, + pub methods: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub capabilities: BTreeMap, + pub limits: Limits, + pub application: A, +} + +impl InitializeResult { + pub fn validate_common( + &self, + expected_protocol: &str, + offered_versions: &[u32], + offered_limits: Limits, + ) -> Result<()> { + if self.protocol != expected_protocol || !offered_versions.contains(&self.version) { + return Err(Error::Protocol( + "server selected an unsupported protocol version", + )); + } + self.server.validate()?; + validate_capabilities(&self.methods)?; + validate_feature_capabilities(&self.capabilities)?; + self.limits.validate()?; + if self.limits.max_frame_bytes > offered_limits.max_frame_bytes + || self.limits.max_in_flight > offered_limits.max_in_flight + { + return Err(Error::Protocol("server selected an unoffered limit")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CancelParams { + pub id: crate::RequestId, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmptyParams {} + +/// Methods a server calls back on the client over the same session (0.20+). +/// +/// This is the only direction reversal in version 1, and it exists because the +/// endpoint that knows a value is missing is never the process that can ask a +/// person for it. A stdio resolver has no terminal at all: its stdin and stdout +/// are the protocol. Prior art solves this the same way, from D-Bus Secret +/// Service returning a prompt object the client must drive, to the editor +/// protocols whose servers request input rather than drawing it. +/// +/// A server MUST NOT send one of these unless the client advertised it in +/// [`InitializeParams::client_methods`]. +pub mod callback { + pub mod method { + /// Ask the client to obtain one secret value from a person (0.20+). + pub const PROMPT: &str = "client.prompt"; + /// Ask the client for one provider authentication credential (0.20+). + pub const CREDENTIAL: &str = "client.credential"; + + pub const RESOLVER: &[&str] = &[PROMPT]; + pub const PROVIDER: &[&str] = &[CREDENTIAL]; + pub const ALL: &[&str] = &[PROMPT, CREDENTIAL]; + } + + use super::*; + + /// Everything the person answering needs, and nothing else. + /// + /// There is no free-form message: the text a client shows is built by the + /// client from these fields, so a server cannot use the prompt to put + /// arbitrary attacker-influenced text in front of a person. The declared + /// name and the credential-free provider URI are already known to the + /// session that asked. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct PromptParams { + /// The declared name whose value is being asked for. + pub name: String, + /// The active profile, so a person answering can tell which one they + /// are provisioning. + pub profile: String, + /// Credential-free display URI of the provider that will store the + /// answer, or absent when the answer is not persisted. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_provider: Option, + } + + impl PromptParams { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("secret name has an invalid byte length", &self.name, 4096)?; + validate_nonempty_bytes("profile has an invalid byte length", &self.profile, 4096)?; + validate_optional_bytes( + "target provider has an invalid byte length", + self.target_provider.as_deref(), + 32768, + ) + } + } + + /// The answer carries a secret and is treated exactly like a resolved + /// value: never logged, and dropped as soon as it is copied. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct PromptResult { + pub value: String, + } + + impl PromptResult { + pub fn validate(&self) -> Result<()> { + // An empty answer is refused for the same reason `resolver.set` + // refuses an empty value: stores disagree about whether one means + // absent or present-and-empty. A person who wants to decline + // cancels instead. + validate_nonempty_bytes( + "prompt answer has an invalid byte length", + &self.value, + ABSOLUTE_MAX_FRAME_BYTES, + ) + } + } + + /// One semantic provider credential requested while an endpoint is + /// initializing or refreshing its authentication (0.20+). + /// + /// `scope` is a stable, credential-free account or store identity chosen + /// by the provider from its configured URI. The client binds it to the + /// already selected provider principal before consulting its credential + /// broker, so it cannot address another provider's credentials. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct CredentialParams { + pub name: String, + pub scope: String, + pub required: bool, + } + + impl CredentialParams { + pub fn validate(&self) -> Result<()> { + validate_semantic_name(&self.name)?; + validate_nonempty_bytes( + "provider credential scope has an invalid byte length", + &self.scope, + 4096, + ) + } + } + + /// A broker lookup is either a secret value or an ordinary miss. Missing + /// is not a transport failure: the endpoint may try another authentication + /// mechanism or return its own actionable authentication error. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] + pub enum CredentialResult { + Found { value: String }, + Missing, + } + + impl CredentialResult { + pub fn validate(&self) -> Result<()> { + match self { + Self::Found { value } => validate_nonempty_bytes( + "provider credential has an invalid byte length", + value, + ABSOLUTE_MAX_FRAME_BYTES, + ), + Self::Missing => Ok(()), + } + } + } +} + +fn validate_semantic_name(name: &str) -> Result<()> { + let mut chars = name.chars(); + if !matches!(chars.next(), Some('a'..='z')) + || chars.any(|character| !matches!(character, 'a'..='z' | '0'..='9' | '_')) + || name.len() > 256 + { + return Err(Error::Protocol("invalid credential semantic name")); + } + Ok(()) +} + +pub(crate) fn validate_capabilities(capabilities: &[String]) -> Result<()> { + if capabilities.iter().collect::>().len() != capabilities.len() { + return Err(Error::Protocol("capabilities must be distinct")); + } + for capability in capabilities { + validate_nonempty_bytes("capability", capability, 256)?; + } + Ok(()) +} + +pub(crate) fn validate_feature_capabilities(capabilities: &BTreeMap) -> Result<()> { + for capability in capabilities.keys() { + validate_nonempty_bytes("capability", capability, 256)?; + } + Ok(()) +} + +pub(crate) fn validate_nonempty_bytes(label: &'static str, value: &str, max: usize) -> Result<()> { + if value.is_empty() || value.len() > max { + Err(Error::Protocol(label)) + } else { + Ok(()) + } +} + +pub(crate) fn validate_optional_bytes( + label: &'static str, + value: Option<&str>, + max: usize, +) -> Result<()> { + if value.is_some_and(|value| value.len() > max) { + Err(Error::Protocol(label)) + } else { + Ok(()) + } +} + +pub(crate) fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +pub mod resolver { + use super::*; + + pub mod method { + pub const GET: &str = "resolver.get"; + pub const RELEASE: &str = "resolver.release"; + /// Store one declared name (0.20+). + pub const SET: &str = "resolver.set"; + /// Remove one declared name's stored value (0.20+). + pub const DELETE: &str = "resolver.delete"; + + /// Every method version 1 defines. What an endpoint advertises is a + /// subset of this: see [`super::CAPABILITIES`] and + /// [`super::MUTATION_CAPABILITIES`]. + pub const ALL: &[&str] = &[GET, RELEASE, SET, DELETE]; + } + + /// Methods every version 1 endpoint answers. A client that negotiates a + /// session without all of them is talking to something that is not a + /// resolver, so it refuses the endpoint rather than degrading. + /// + pub const CAPABILITIES: &[&str] = &[method::GET, method::RELEASE]; + + /// Methods that write to the store (0.20+). + /// + /// These are optional and separately advertised: resolution is the reason + /// the protocol exists, while storage is authority a consumer usually does + /// not need. An endpoint serving a read-only consumer, or one an operator + /// started read-only, advertises none of them, and the client will not send + /// a method that was not advertised. + pub const MUTATION_CAPABILITIES: &[&str] = &[method::SET, method::DELETE]; + + /// Rejects a capability list that no version 1 endpoint could honor. + /// + /// Only the base methods are required. Nothing here constrains the + /// mutation methods against each other: an endpoint that can store but not + /// remove is a store that only appends, which is a real backend and not a + /// malformed advertisement. + pub fn validate_capabilities(capabilities: &[String]) -> Result<()> { + if !CAPABILITIES + .iter() + .all(|method| capabilities.iter().any(|item| item == method)) + { + return Err(Error::Protocol( + "resolver capability set omits a required method", + )); + } + Ok(()) + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] + pub enum Manifest { + Path { path: String }, + Inline { toml: String, base_dir: String }, + } + + impl Manifest { + pub fn validate(&self) -> Result<()> { + match self { + Self::Path { path } => validate_absolute_path(path), + Self::Inline { toml, base_dir } => { + if toml.len() > ABSOLUTE_MAX_FRAME_BYTES { + return Err(Error::Protocol("inline manifest is too large")); + } + validate_absolute_path(base_dir) + } + } + } + + pub const fn kind(&self) -> &'static str { + match self { + Self::Path { .. } => "path", + Self::Inline { .. } => "inline", + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct InitializeApplication { + pub manifest: Manifest, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub provider: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub profile: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub scope: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub reason: Option, + /// App-requested authorization lifetime in milliseconds. The provider + /// may shorten, extend, or reject this request after user approval. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_authorization_duration_ms: Option, + } + + impl InitializeApplication { + pub fn validate(&self) -> Result<()> { + self.manifest.validate()?; + validate_optional_bytes( + "provider override is too long", + self.provider.as_deref(), + 32768, + )?; + validate_optional_bytes("profile is too long", self.profile.as_deref(), 4096)?; + validate_optional_bytes("scope is too long", self.scope.as_deref(), 4096)?; + validate_optional_bytes("reason is too long", self.reason.as_deref(), 4096)?; + if self.requested_authorization_duration_ms == Some(0) { + return Err(Error::Protocol( + "requested authorization duration must be positive", + )); + } + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct InitializedApplication { + pub manifest_kind: String, + pub supports_inline_manifest: bool, + } + + impl InitializedApplication { + pub fn validate(&self) -> Result<()> { + if matches!(self.manifest_kind.as_str(), "path" | "inline") { + Ok(()) + } else { + Err(Error::Protocol("invalid initialized manifest kind")) + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + /// Which shape of value the caller can accept. + /// + /// Both spellings describe what the returned string contains: the secret + /// itself, or a location to read it from. The file the resolver writes for + /// the `Path` form is how that location is produced, not something the + /// caller selects, which is why it does not appear here. + pub enum Representation { + Auto, + Value, + Path, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Purpose { + pub consumer: String, + pub operation: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + } + + impl Purpose { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes( + "purpose consumer has an invalid byte length", + &self.consumer, + 256, + )?; + validate_nonempty_bytes( + "purpose operation has an invalid byte length", + &self.operation, + 256, + )?; + validate_optional_bytes("purpose host is too long", self.host.as_deref(), 4096)?; + validate_optional_bytes("purpose path is too long", self.path.as_deref(), 4096) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct GetParams { + pub name: String, + pub representation: Representation, + pub purpose: Purpose, + } + + impl GetParams { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("secret name has an invalid byte length", &self.name, 4096)?; + self.purpose.validate() + } + } + + /// Where a resolved value came from. + /// + /// Provenance, not an authorization input. The set is closed for the + /// resolver and open for the client, under the same rule as + /// [`crate::error::ErrorKind`]: a later revision naming a new origin, such + /// as a dynamically issued credential, must not kill a session with an + /// older client. A client that treats provenance as security-relevant reads + /// [`Source::Unrecognized`] as "not one of the origins I can vouch for" and + /// decides accordingly, which it could not do if the frame failed to parse. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Source { + Provider, + Generated, + Default, + Composed, + /// An origin this revision does not define. A resolver never sends it. + Unrecognized, + } + + impl Source { + pub const fn as_str(self) -> &'static str { + match self { + Self::Provider => "provider", + Self::Generated => "generated", + Self::Default => "default", + Self::Composed => "composed", + Self::Unrecognized => "unrecognized", + } + } + + pub const DEFINED: &'static [Self] = &[ + Self::Provider, + Self::Generated, + Self::Default, + Self::Composed, + ]; + + pub fn from_wire(source: &str) -> Self { + Self::DEFINED + .iter() + .copied() + .find(|defined| defined.as_str() == source) + .unwrap_or(Self::Unrecognized) + } + } + + impl Serialize for Source { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for Source { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + Ok(Self::from_wire(&String::deserialize(deserializer)?)) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct UndeclaredResult { + pub status: UndeclaredStatus, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum UndeclaredStatus { + Undeclared, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct MissingResult { + pub status: MissingStatus, + pub required: bool, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum MissingStatus { + Missing, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct ResolvedValueResult { + pub status: ResolvedStatus, + pub representation: ValueRepresentation, + pub value: String, + pub source: Source, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_provider: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub expires_at_unix_ms: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub refresh_at_unix_ms: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct ResolvedPathResult { + pub status: ResolvedStatus, + pub representation: PathRepresentation, + pub path: String, + /// Releases the resolver-owned file behind [`Self::path`]. Named apart + /// from a provider credential lease, which is an unrelated concept + /// with its own issuance and revocation. + pub path_lease_id: String, + pub source: Source, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_provider: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub expires_at_unix_ms: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub refresh_at_unix_ms: Option, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum ResolvedStatus { + Resolved, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum ValueRepresentation { + Value, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum PathRepresentation { + Path, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(untagged)] + pub enum GetResult { + Undeclared(UndeclaredResult), + Missing(MissingResult), + Value(ResolvedValueResult), + Path(ResolvedPathResult), + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SetParams { + pub name: String, + pub value: String, + pub purpose: Purpose, + } + + impl SetParams { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("secret name has an invalid byte length", &self.name, 4096)?; + // An empty value means "no value" to some stores and "a value that + // happens to be empty" to others, and the difference decides + // whether a later read finds the secret. It never travels as a + // write; a caller that wants the value gone sends `resolver.delete`. + validate_nonempty_bytes( + "secret value has an invalid byte length", + &self.value, + ABSOLUTE_MAX_FRAME_BYTES, + )?; + self.purpose.validate() + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum StoredStatus { + Stored, + } + + /// A write is either accepted or refused, so there is one shape here where + /// a read has four. A name the manifest does not declare has no address to + /// write to, and unlike an absent value it is not something the caller can + /// route around, so it is an error rather than a status. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SetResult { + pub status: StoredStatus, + /// Credential-free URI of the provider that took the write, for a + /// consumer that reports the destination back to a human. Absent when + /// the endpoint does not name it. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_provider: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct DeleteParams { + pub name: String, + pub purpose: Purpose, + } + + impl DeleteParams { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("secret name has an invalid byte length", &self.name, 4096)?; + self.purpose.validate() + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum DeletedStatus { + Deleted, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct DeleteResult { + pub status: DeletedStatus, + /// `false` when the store held nothing for this name. Removal is + /// idempotent, so that is a success and not an error. + pub deleted: bool, + /// Credential-free URI of the provider the removal was addressed to. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_provider: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct ReleaseParams { + pub path_lease_ids: Vec, + } + + impl ReleaseParams { + pub fn validate(&self) -> Result<()> { + if self.path_lease_ids.len() > 256 { + return Err(Error::Protocol("release contains more than 256 lease IDs")); + } + for lease in &self.path_lease_ids { + validate_nonempty_bytes("lease ID has an invalid byte length", lease, 256)?; + } + Ok(()) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub struct ReleaseResult { + pub released: usize, + } + + fn validate_absolute_path(path: &str) -> Result<()> { + validate_nonempty_bytes("path has an invalid byte length", path, 32768)?; + let path = std::path::Path::new(path); + if !path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) + { + return Err(Error::Protocol( + "manifest path must be absolute and lexically normalized", + )); + } + Ok(()) + } +} + +pub mod provider { + use super::*; + + pub mod method { + use super::*; + use serde::de::DeserializeOwned; + + /// Associates one provider method with its wire parameter and result + /// types so callers cannot accidentally deserialize a method into the + /// wrong response shape. + pub trait Method { + const NAME: &'static str; + type Params: Serialize; + type Result: DeserializeOwned; + } + + macro_rules! methods { + ($(($type:ident, $constant:ident, $name:literal, $params:ty, $result:ty)),+ $(,)?) => { + $( + pub const $constant: &str = $name; + + #[derive(Debug, Clone, Copy)] + pub struct $type; + + impl Method for $type { + const NAME: &'static str = $constant; + type Params = $params; + type Result = $result; + } + )+ + + pub const ALL: &[&str] = &[$($constant),+]; + }; + } + + methods!( + ( + ResolveAddress, + RESOLVE_ADDRESS, + "provider.resolve_address", + AddressParams, + ResolveAddressResult + ), + (Get, GET, "provider.get", AddressParams, GetResult), + ( + GetMany, + GET_MANY, + "provider.get_many", + GetManyParams, + GetManyResult + ), + ( + Exists, + EXISTS, + "provider.exists", + AddressParams, + ExistsResult + ), + (Set, SET, "provider.set", SetParams, StoredResult), + ( + SetExpiring, + SET_EXPIRING, + "provider.set_expiring", + SetExpiringParams, + StoredResult + ), + ( + Delete, + DELETE, + "provider.delete", + AddressParams, + DeletedResult + ), + (Clear, CLEAR, "provider.clear", ClearParams, ClearResult), + ( + CheckWritable, + CHECK_WRITABLE, + "provider.check_writable", + AddressParams, + EmptyResult + ), + ( + CheckDeletable, + CHECK_DELETABLE, + "provider.check_deletable", + AddressParams, + EmptyResult + ), + ( + DescribeWriteTarget, + DESCRIBE_WRITE_TARGET, + "provider.describe_write_target", + AddressParams, + DescribeWriteTargetResult + ), + ( + Reflect, + REFLECT, + "provider.reflect", + ReflectParams, + ReflectResult + ), + ); + } + + pub const CAPABILITIES: &[&str] = method::ALL; + + pub fn validate_capabilities(capabilities: &[String]) -> Result<()> { + let has = |method: &str| capabilities.iter().any(|item| item == method); + if !has(method::RESOLVE_ADDRESS) + || ![method::GET, method::EXISTS, method::SET] + .iter() + .any(|method| has(method)) + || (has(method::GET_MANY) && !has(method::GET)) + || (has(method::SET_EXPIRING) && !has(method::SET)) + { + return Err(Error::Protocol( + "provider capability set violates version 1 dependencies", + )); + } + Ok(()) + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + /// Resolver-declared provider session context. Available starting with + /// SecretSpec 0.20. + pub struct ApplicationContext { + #[serde(deserialize_with = "deserialize_required_nullable")] + pub project: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub profile: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub base_dir: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub reason: Option, + /// App-requested authorization lifetime in milliseconds. This is an + /// untrusted default for an approval surface, not an authorization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_authorization_duration_ms: Option, + } + + impl ApplicationContext { + pub fn validate(&self) -> Result<()> { + for (label, value) in [ + ("provider project", self.project.as_deref()), + ("provider profile", self.profile.as_deref()), + ] { + if let Some(value) = value { + validate_nonempty_bytes(label, value, 4096)?; + } + } + validate_optional_bytes( + "provider base directory is too long", + self.base_dir.as_deref(), + 32768, + )?; + validate_optional_bytes("provider reason is too long", self.reason.as_deref(), 4096)?; + if self.requested_authorization_duration_ms == Some(0) { + return Err(Error::Protocol( + "requested authorization duration must be positive", + )); + } + if let Some(base_dir) = &self.base_dir + && !std::path::Path::new(base_dir).is_absolute() + { + return Err(Error::Protocol("provider base directory must be absolute")); + } + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct InitializeApplication { + pub scheme: String, + pub uri: String, + pub context: ApplicationContext, + } + + impl InitializeApplication { + pub fn validate(&self) -> Result<()> { + validate_scheme(&self.scheme)?; + validate_nonempty_bytes("provider URI has an invalid byte length", &self.uri, 32768)?; + self.context.validate()?; + let uri_scheme = self.uri.split_once(':').map(|(scheme, _)| scheme); + if uri_scheme != Some(self.scheme.as_str()) { + return Err(Error::Protocol( + "provider URI scheme does not match initialization scheme", + )); + } + Ok(()) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum CoordinateName { + Field, + Vault, + Section, + Version, + } + + impl CoordinateName { + pub const fn as_str(self) -> &'static str { + match self { + Self::Field => "field", + Self::Vault => "vault", + Self::Section => "section", + Self::Version => "version", + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum Persistence { + Persist, + Ephemeral, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Metadata { + pub name: String, + pub display_uri: String, + pub supported_coordinates: Vec, + pub generated_value_persistence: Persistence, + pub prompted_value_persistence: Persistence, + pub storage_identity: String, + pub entry_container_identity: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub physical_store_path: Option, + } + + impl Metadata { + pub fn validate(&self) -> Result<()> { + validate_scheme(&self.name)?; + for value in [ + &self.display_uri, + &self.storage_identity, + &self.entry_container_identity, + ] { + if value.len() > 32768 { + return Err(Error::Protocol("provider metadata is too long")); + } + } + if self + .supported_coordinates + .iter() + .collect::>() + .len() + != self.supported_coordinates.len() + { + return Err(Error::Protocol("supported coordinates must be distinct")); + } + if let Some(path) = &self.physical_store_path + && !std::path::Path::new(path).is_absolute() + { + return Err(Error::Protocol("physical_store_path must be absolute")); + } + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct InitializedApplication { + pub provider: Metadata, + } + + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Coordinates { + pub item: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vault: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + } + + impl Coordinates { + pub fn validate(&self) -> Result<()> { + validate_nonempty_bytes("item has an invalid byte length", &self.item, 4096)?; + for value in [&self.field, &self.vault, &self.section, &self.version] { + validate_optional_bytes("coordinate is too long", value.as_deref(), 4096)?; + } + Ok(()) + } + + pub fn unsupported(&self, supported: &[CoordinateName]) -> Option { + [ + (CoordinateName::Field, self.field.is_some()), + (CoordinateName::Vault, self.vault.is_some()), + (CoordinateName::Section, self.section.is_some()), + (CoordinateName::Version, self.version.is_some()), + ] + .into_iter() + .find_map(|(name, present)| (present && !supported.contains(&name)).then_some(name)) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] + pub enum Address { + Convention { + project: String, + profile: String, + key: String, + }, + Native { + coordinates: Coordinates, + }, + } + + impl Address { + pub fn validate(&self) -> Result<()> { + match self { + Self::Convention { + project, + profile, + key, + } => { + for value in [project, profile, key] { + if value.len() > 4096 { + return Err(Error::Protocol( + "convention address component is too long", + )); + } + } + Ok(()) + } + Self::Native { coordinates } => coordinates.validate(), + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct AddressParams { + pub address: Address, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct ResolveAddressResult { + pub coordinates: Coordinates, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "status", rename_all = "snake_case")] + pub enum GetResult { + Found { + value: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + expires_at_unix_ms: Option, + }, + Missing, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct NamedRequest { + pub name: String, + pub address: Address, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct GetManyParams { + pub requests: Vec, + } + + impl GetManyParams { + pub fn validate(&self) -> Result<()> { + if self.requests.len() > 1024 { + return Err(Error::Protocol("get_many contains more than 1024 requests")); + } + let mut names = BTreeSet::new(); + for request in &self.requests { + validate_nonempty_bytes( + "batch name has an invalid byte length", + &request.name, + 4096, + )?; + if !names.insert(&request.name) { + return Err(Error::Protocol("batch names must be unique")); + } + request.address.validate()?; + } + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + pub struct NamedGetResult { + pub name: String, + #[serde(flatten)] + pub outcome: GetResult, + } + + impl<'de> Deserialize<'de> for NamedGetResult { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Found { + name: String, + status: FoundStatus, + value: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + expires_at_unix_ms: Option, + } + + #[derive(Deserialize)] + struct Missing { + name: String, + status: MissingStatus, + } + + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + enum FoundStatus { + Found, + } + + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + enum MissingStatus { + Missing, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Found(Found), + Missing(Missing), + } + + match Repr::deserialize(deserializer)? { + Repr::Found(Found { + name, + status: FoundStatus::Found, + value, + expires_at_unix_ms, + }) => Ok(Self { + name, + outcome: GetResult::Found { + value, + expires_at_unix_ms, + }, + }), + Repr::Missing(Missing { + name, + status: MissingStatus::Missing, + }) => Ok(Self { + name, + outcome: GetResult::Missing, + }), + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct GetManyResult { + pub results: Vec, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub struct ExistsResult { + pub exists: bool, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SetParams { + pub address: Address, + pub value: String, + } + + impl SetParams { + pub fn validate(&self) -> Result<()> { + self.address.validate() + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SetExpiringParams { + pub address: Address, + pub value: String, + pub ttl_ms: u64, + } + + impl SetExpiringParams { + pub fn validate(&self) -> Result<()> { + self.address.validate()?; + if self.ttl_ms == 0 { + return Err(Error::Protocol("provider expiry must be positive")); + } + Ok(()) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub struct StoredResult { + pub stored: bool, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub struct DeletedResult { + pub deleted: bool, + } + + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] + pub struct EmptyResult {} + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] + pub enum ClearScope { + All, + Convention { project: String, profile: String }, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct ClearParams { + pub scope: ClearScope, + } + + impl ClearParams { + pub fn validate(&self) -> Result<()> { + match &self.scope { + ClearScope::All => Ok(()), + ClearScope::Convention { project, profile } => { + validate_optional_bytes("clear project is too long", Some(project), 4096)?; + validate_optional_bytes("clear profile is too long", Some(profile), 4096) + } + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub struct ClearResult { + pub cleared: usize, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct DescribeWriteTargetResult { + pub description: String, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct ReflectParams { + pub project: String, + pub profile: String, + } + + impl ReflectParams { + pub fn validate(&self) -> Result<()> { + validate_optional_bytes("reflection project is too long", Some(&self.project), 4096)?; + validate_optional_bytes("reflection profile is too long", Some(&self.profile), 4096) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct ReflectedDeclaration { + pub description: String, + pub required: bool, + #[serde(rename = "ref")] + pub reference: Coordinates, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct ReflectResult { + pub schema_version: u32, + pub declarations: BTreeMap, + } + + impl ReflectResult { + pub fn validate(&self) -> Result<()> { + if self.schema_version != 1 { + return Err(Error::Protocol("unsupported reflection schema version")); + } + for (name, declaration) in &self.declarations { + validate_optional_bytes("reflected name is too long", Some(name), 4096)?; + validate_optional_bytes( + "reflected description is too long", + Some(&declaration.description), + 4096, + )?; + declaration.reference.validate()?; + } + Ok(()) + } + } + + fn validate_scheme(scheme: &str) -> Result<()> { + let mut chars = scheme.chars(); + let first = chars.next(); + if !matches!(first, Some('a'..='z')) + || chars.any(|character| !matches!(character, 'a'..='z' | '0'..='9' | '-')) + { + return Err(Error::Protocol("invalid provider scheme")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limit_selection_never_increases_an_offer() { + let selected = Limits { + max_frame_bytes: 32_768, + max_in_flight: 16, + } + .select(Limits { + max_frame_bytes: 8_192, + max_in_flight: 4, + }) + .unwrap(); + assert_eq!(selected.max_frame_bytes, 8_192); + assert_eq!(selected.max_in_flight, 4); + } + + #[test] + fn provider_uri_scheme_must_match() { + let application = provider::InitializeApplication { + scheme: "factorseal".into(), + uri: "other://default".into(), + context: provider::ApplicationContext { + project: Some("demo".into()), + profile: Some("default".into()), + base_dir: None, + reason: None, + requested_authorization_duration_ms: None, + }, + }; + assert!(application.validate().is_err()); + } + + #[test] + fn provider_credential_requests_use_bounded_semantic_names_and_scopes() { + let valid = callback::CredentialParams { + name: "client_secret_2".into(), + scope: "https://service.example/account/team-a".into(), + required: true, + }; + assert!(valid.validate().is_ok()); + for name in ["", "ClientSecret", "2fa", "client-secret"] { + assert!( + callback::CredentialParams { + name: name.into(), + ..valid.clone() + } + .validate() + .is_err() + ); + } + assert!( + callback::CredentialParams { + scope: String::new(), + ..valid.clone() + } + .validate() + .is_err() + ); + assert!( + callback::CredentialParams { + scope: "x".repeat(4097), + ..valid + } + .validate() + .is_err() + ); + } + + #[test] + fn provider_credential_results_distinguish_a_miss_from_an_empty_secret() { + assert!(callback::CredentialResult::Missing.validate().is_ok()); + assert!( + callback::CredentialResult::Found { + value: "token".into() + } + .validate() + .is_ok() + ); + assert!( + callback::CredentialResult::Found { + value: String::new() + } + .validate() + .is_err() + ); + } + + #[test] + fn named_provider_batch_results_are_flattened_and_closed() { + let found = serde_json::from_value::(serde_json::json!({ + "name": "token", + "status": "found", + "value": "secret", + "expires_at_unix_ms": null + })) + .unwrap(); + assert_eq!( + found, + provider::NamedGetResult { + name: "token".into(), + outcome: provider::GetResult::Found { + value: "secret".into(), + expires_at_unix_ms: None, + } + } + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "name": "token", + "status": "missing", + "extra": true + })) + .is_ok() + ); + } + + #[test] + fn provider_empty_results_ignore_future_members() { + assert!(serde_json::from_value::(serde_json::json!({})).is_ok()); + assert!( + serde_json::from_value::(serde_json::json!({"stored": true})) + .is_ok() + ); + } + + #[test] + fn schema_required_nullable_members_cannot_be_omitted() { + assert!( + serde_json::from_value::(serde_json::json!({ + "manifest": {"kind": "path", "path": "/tmp/secretspec.toml"}, + "provider": null, + "profile": null, + "scope": null + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "scheme": "factorseal", + "uri": "factorseal://default", + "context": { + "project": "demo", + "profile": "default", + "base_dir": null + } + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "status": "found", + "value": "secret" + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "status": "resolved", + "representation": "value", + "value": "secret", + "source": "provider", + "source_provider": null, + "expires_at_unix_ms": null + })) + .is_err() + ); + } + + #[test] + fn requested_authorization_duration_is_optional_but_must_be_positive() { + let without_request = + serde_json::from_value::(serde_json::json!({ + "manifest": {"kind": "path", "path": "/tmp/secretspec.toml"}, + "provider": null, + "profile": null, + "scope": null, + "reason": null + })) + .unwrap(); + assert_eq!(without_request.requested_authorization_duration_ms, None); + + let zero = resolver::InitializeApplication { + requested_authorization_duration_ms: Some(0), + ..without_request + }; + assert!(zero.validate().is_err()); + } +} diff --git a/secretspec-ipc/src/provider.rs b/secretspec-ipc/src/provider.rs new file mode 100644 index 000000000..ebfa55a5e --- /dev/null +++ b/secretspec-ipc/src/provider.rs @@ -0,0 +1,441 @@ +use crate::error::{ErrorKind, RpcError}; +use crate::protocol::PROVIDER_PROTOCOL; +use crate::protocol::callback::{self, CredentialParams, CredentialResult}; +use crate::protocol::provider::{ + Address, AddressParams, CAPABILITIES, ClearParams, ClearResult, DescribeWriteTargetResult, + ExistsResult, GetManyParams, GetManyResult, GetResult, InitializeApplication, + InitializedApplication, Metadata, ReflectParams, ReflectResult, ResolveAddressResult, + SetExpiringParams, SetParams, method, +}; +use crate::server::{ApplicationHandler, RequestContext, RpcResult, ServerConfig}; +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::sync::OnceLock; +use zeroize::Zeroizing; + +/// Owned value passed across the endpoint-author boundary. Its backing bytes +/// are cleared on drop independently of the JSON frame buffer. +#[derive(Debug, Clone)] +pub struct SecretValue(Zeroizing); + +impl SecretValue { + pub fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + + pub fn expose(&self) -> &str { + self.0.as_str() + } + + /// Copies the value out for JSON serialization. + /// + /// Deliberately a copy rather than `mem::take`: taking would move the + /// backing allocation out of the `Zeroizing` wrapper, leaving it to wipe an + /// empty string while the real bytes lived on un-wiped. Copying keeps this + /// value's own buffer covered by its destructor. + fn into_string(self) -> String { + self.0.as_str().to_owned() + } +} + +/// A provider value and the absolute time at which the value itself expires. +/// +/// `None` means the provider does not know a validity bound; it does not mean +/// the value is permanent. Cache freshness is owned by the resolver and is not +/// represented here. +#[derive(Debug, Clone)] +pub struct ProvidedSecret { + value: SecretValue, + expires_at_unix_ms: Option, +} + +impl ProvidedSecret { + pub fn new(value: String, expires_at_unix_ms: Option) -> Self { + Self { + value: SecretValue::new(value), + expires_at_unix_ms, + } + } + + pub fn value(&self) -> &str { + self.value.expose() + } + + pub const fn expires_at_unix_ms(&self) -> Option { + self.expires_at_unix_ms + } + + fn into_parts(self) -> (String, Option) { + (self.value.into_string(), self.expires_at_unix_ms) + } +} + +#[async_trait] +pub trait ProviderHandler: Send + Sync + 'static { + /// Supported operation names. `provider.resolve_address` is mandatory. + fn capabilities(&self) -> Vec; + + async fn initialize( + &self, + context: &RequestContext, + application: InitializeApplication, + ) -> RpcResult; + + async fn resolve_address( + &self, + context: RequestContext, + address: Address, + ) -> RpcResult; + + async fn get( + &self, + _context: RequestContext, + _address: Address, + ) -> RpcResult> { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn get_many( + &self, + _context: RequestContext, + _params: GetManyParams, + ) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn exists(&self, _context: RequestContext, _address: Address) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn set( + &self, + _context: RequestContext, + _address: Address, + _value: SecretValue, + ) -> RpcResult<()> { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn set_expiring( + &self, + _context: RequestContext, + _address: Address, + _value: SecretValue, + _ttl_ms: u64, + ) -> RpcResult<()> { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn delete(&self, _context: RequestContext, _address: Address) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn clear(&self, _context: RequestContext, _params: ClearParams) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn check_writable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn check_deletable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn describe_write_target( + &self, + _context: RequestContext, + _address: Address, + ) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn reflect( + &self, + _context: RequestContext, + _params: ReflectParams, + ) -> RpcResult { + Err(RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn shutdown(&self) {} +} + +/// Requests one provider credential from the client while serving the current +/// request, normally from [`ProviderHandler::initialize`] (0.20+). +/// +/// A client that did not advertise credential brokerage behaves like an empty +/// broker, allowing an endpoint to retain native environment, agent, or +/// workload-identity fallbacks without treating callback support as mandatory. +pub async fn request_credential( + context: &RequestContext, + params: CredentialParams, +) -> RpcResult> { + params + .validate() + .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + if !context.peer.supports(callback::method::CREDENTIAL) { + return Ok(None); + } + let result: CredentialResult = context + .peer + .call(callback::method::CREDENTIAL, ¶ms, context) + .await?; + result + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + Ok(match result { + CredentialResult::Found { value } => Some(SecretValue::new(value)), + CredentialResult::Missing => None, + }) +} + +struct ProviderApplication { + handler: Arc, + metadata: OnceLock, +} + +impl ProviderApplication { + fn new(handler: Arc) -> Self { + Self { + handler, + metadata: OnceLock::new(), + } + } + + fn validate_address(&self, address: &Address) -> RpcResult<()> { + address.validate().map_err(invalid_params)?; + let metadata = self + .metadata + .get() + .ok_or_else(|| RpcError::new(ErrorKind::Internal))?; + if let Address::Native { coordinates } = address + && coordinates + .unsupported(&metadata.supported_coordinates) + .is_some() + { + return Err(RpcError::new(ErrorKind::InvalidParams)); + } + Ok(()) + } + + fn address_params(&self, value: Value) -> RpcResult { + let params: AddressParams = parse(value)?; + self.validate_address(¶ms.address)?; + Ok(params) + } +} + +#[async_trait] +impl ApplicationHandler for ProviderApplication { + fn protocol(&self) -> &'static str { + PROVIDER_PROTOCOL + } + + fn capabilities(&self) -> Vec { + let mut capabilities = self.handler.capabilities(); + capabilities.retain(|capability| CAPABILITIES.contains(&capability.as_str())); + capabilities.sort(); + capabilities.dedup(); + capabilities + } + + fn validate_capabilities(&self, capabilities: &[String]) -> RpcResult<()> { + crate::protocol::provider::validate_capabilities(capabilities) + .map_err(|_| RpcError::new(ErrorKind::CapabilityRequired)) + } + + async fn initialize(&self, context: &RequestContext, application: Value) -> RpcResult { + let application: InitializeApplication = parse(application)?; + application.validate().map_err(invalid_params)?; + let scheme = application.scheme.clone(); + let metadata = self.handler.initialize(context, application).await?; + metadata + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + if metadata.name != scheme { + return Err(RpcError::new(ErrorKind::Conflict)); + } + self.metadata + .set(metadata.clone()) + .map_err(|_| RpcError::new(ErrorKind::Conflict))?; + serde_json::to_value(InitializedApplication { provider: metadata }) + .map_err(|_| RpcError::new(ErrorKind::Internal)) + } + + async fn call(&self, context: RequestContext, method: &str, params: Value) -> RpcResult { + match method { + method::RESOLVE_ADDRESS => { + let params = self.address_params(params)?; + let result = self + .handler + .resolve_address(context, params.address) + .await?; + result + .coordinates + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + if result + .coordinates + .unsupported( + &self + .metadata + .get() + .expect("provider initialized before calls") + .supported_coordinates, + ) + .is_some() + { + return Err(RpcError::new(ErrorKind::OperationFailed)); + } + encode(result) + } + method::GET => { + let params = self.address_params(params)?; + let result = match self.handler.get(context, params.address).await? { + Some(value) => { + let (value, expires_at_unix_ms) = value.into_parts(); + GetResult::Found { + value, + expires_at_unix_ms, + } + } + None => GetResult::Missing, + }; + encode(result) + } + method::GET_MANY => { + let params: GetManyParams = parse(params)?; + params.validate().map_err(invalid_params)?; + for request in ¶ms.requests { + self.validate_address(&request.address)?; + } + let expected = params + .requests + .iter() + .map(|request| request.name.clone()) + .collect::>(); + let result = self.handler.get_many(context, params).await?; + if result.results.len() != expected.len() + || result + .results + .iter() + .zip(expected) + .any(|(result, expected)| result.name != expected) + { + return Err(RpcError::new(ErrorKind::OperationFailed)); + } + encode(result) + } + method::EXISTS => { + let params = self.address_params(params)?; + encode(ExistsResult { + exists: self.handler.exists(context, params.address).await?, + }) + } + method::SET => { + let params: SetParams = parse(params)?; + params.validate().map_err(invalid_params)?; + self.validate_address(¶ms.address)?; + self.handler + .set(context, params.address, SecretValue::new(params.value)) + .await?; + Ok(json!({"stored": true})) + } + method::SET_EXPIRING => { + let params: SetExpiringParams = parse(params)?; + params.validate().map_err(invalid_params)?; + self.validate_address(¶ms.address)?; + self.handler + .set_expiring( + context, + params.address, + SecretValue::new(params.value), + params.ttl_ms, + ) + .await?; + Ok(json!({"stored": true})) + } + method::DELETE => { + let params = self.address_params(params)?; + Ok(json!({"deleted": self.handler.delete(context, params.address).await?})) + } + method::CLEAR => { + let params: ClearParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let cleared = self.handler.clear(context, params).await?; + encode(ClearResult { cleared }) + } + method::CHECK_WRITABLE => { + let params = self.address_params(params)?; + self.handler.check_writable(context, params.address).await?; + Ok(json!({})) + } + method::CHECK_DELETABLE => { + let params = self.address_params(params)?; + self.handler + .check_deletable(context, params.address) + .await?; + Ok(json!({})) + } + method::DESCRIBE_WRITE_TARGET => { + let params = self.address_params(params)?; + encode(DescribeWriteTargetResult { + description: self + .handler + .describe_write_target(context, params.address) + .await?, + }) + } + method::REFLECT => { + let params: ReflectParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let result = self.handler.reflect(context, params).await?; + result + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + encode(result) + } + _ => Err(RpcError::new(ErrorKind::MethodNotFound)), + } + } + + async fn shutdown(&self) { + self.handler.shutdown().await; + } +} + +fn parse(value: Value) -> RpcResult { + serde_json::from_value(value).map_err(|_| RpcError::new(ErrorKind::InvalidParams)) +} + +fn encode(value: T) -> RpcResult { + serde_json::to_value(value).map_err(|_| RpcError::new(ErrorKind::Internal)) +} + +fn invalid_params(_: crate::Error) -> RpcError { + RpcError::new(ErrorKind::InvalidParams) +} + +/// Serve one typed provider endpoint without assembling the generic adapter. +pub async fn serve_provider( + reader: R, + writer: W, + handler: H, + config: ServerConfig, +) -> crate::Result<()> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, + H: ProviderHandler, +{ + crate::server::serve( + reader, + writer, + Arc::new(ProviderApplication::new(Arc::new(handler))), + config, + ) + .await +} diff --git a/secretspec-ipc/src/resolver.rs b/secretspec-ipc/src/resolver.rs new file mode 100644 index 000000000..d5380a9d2 --- /dev/null +++ b/secretspec-ipc/src/resolver.rs @@ -0,0 +1,196 @@ +use crate::error::{ErrorKind, RpcError}; +use crate::protocol::RESOLVER_PROTOCOL; +use crate::protocol::callback::{self, PromptParams, PromptResult}; +use crate::protocol::resolver::{ + CAPABILITIES, DeleteParams, DeleteResult, GetParams, GetResult, InitializeApplication, + InitializedApplication, ReleaseParams, ReleaseResult, SetParams, SetResult, method, + validate_capabilities, +}; +use crate::server::{ApplicationHandler, RequestContext, RpcResult, ServerConfig}; +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::sync::Arc; + +/// Typed northbound handler. Implementations never parse JSON-RPC envelopes or +/// arbitrate cancellation/terminal races. +#[async_trait] +pub trait ResolverHandler: Send + Sync + 'static { + async fn initialize( + &self, + context: &RequestContext, + application: InitializeApplication, + ) -> RpcResult; + + async fn get(&self, context: RequestContext, params: GetParams) -> RpcResult; + + async fn release( + &self, + context: RequestContext, + params: ReleaseParams, + ) -> RpcResult; + + /// Methods this endpoint advertises. The default is resolution only, so an + /// endpoint gains a mutation method by naming it here and implementing it, + /// never by inheriting one it never considered. + fn capabilities(&self) -> Vec { + CAPABILITIES + .iter() + .map(|item| (*item).to_string()) + .collect() + } + + /// Store one declared name (0.20+). Unreachable unless + /// [`Self::capabilities`] advertises `resolver.set`: the server answers an + /// unadvertised method itself and never reaches the handler. + async fn set(&self, _context: RequestContext, _params: SetParams) -> RpcResult { + Err(RpcError::new(ErrorKind::MethodNotFound)) + } + + /// Remove one declared name's stored value (0.20+), advertised as + /// `resolver.delete` under the same rule as [`Self::set`]. + async fn delete( + &self, + _context: RequestContext, + _params: DeleteParams, + ) -> RpcResult { + Err(RpcError::new(ErrorKind::MethodNotFound)) + } + + async fn request_finished(&self, _request_id: crate::RequestId, _committed: bool) {} + + async fn shutdown(&self) {} +} + +struct ResolverApplication { + handler: Arc, +} + +impl ResolverApplication { + fn new(handler: Arc) -> Self { + Self { handler } + } +} + +#[async_trait] +impl ApplicationHandler for ResolverApplication { + fn protocol(&self) -> &'static str { + RESOLVER_PROTOCOL + } + + fn capabilities(&self) -> Vec { + self.handler.capabilities() + } + + // The advertised list stopped being a constant once mutation methods became + // opt-in, so an implementation can now omit a method version 1 requires. + // That is a defect in the endpoint rather than anything a peer did, which is + // why the server reports it as `internal`. + fn validate_capabilities(&self, capabilities: &[String]) -> RpcResult<()> { + validate_capabilities(capabilities).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + + async fn initialize(&self, context: &RequestContext, application: Value) -> RpcResult { + let application: InitializeApplication = parse(application)?; + application.validate().map_err(invalid_params)?; + let initialized = self.handler.initialize(context, application).await?; + initialized + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + serde_json::to_value(initialized).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + + async fn call(&self, context: RequestContext, method: &str, params: Value) -> RpcResult { + match method { + method::GET => { + let params: GetParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let result = self.handler.get(context, params).await?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + method::RELEASE => { + let params: ReleaseParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let result = self.handler.release(context, params).await?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + method::SET => { + let params: SetParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let result = self.handler.set(context, params).await?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + method::DELETE => { + let params: DeleteParams = parse(params)?; + params.validate().map_err(invalid_params)?; + let result = self.handler.delete(context, params).await?; + serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal)) + } + _ => Err(RpcError::new(ErrorKind::MethodNotFound)), + } + } + + async fn request_finished(&self, request_id: crate::RequestId, committed: bool) { + self.handler.request_finished(request_id, committed).await; + } + + async fn shutdown(&self) { + self.handler.shutdown().await; + } +} + +/// Ask the client for one secret value, on behalf of the request in `context` +/// (0.20+). +/// +/// Returns `interaction_required` when the client advertised no way to ask, +/// which is the answer a headless consumer needs immediately rather than after +/// its deadline elapses. The prompt inherits the originating request's deadline +/// and cancellation, so it cannot outlive the resolve that raised it. +pub async fn prompt(context: &RequestContext, params: &PromptParams) -> RpcResult { + if !context.peer.supports(callback::method::PROMPT) { + return Err(RpcError::new(ErrorKind::InteractionRequired)); + } + params + .validate() + .map_err(|_| RpcError::new(ErrorKind::Internal))?; + let result: PromptResult = context + .peer + .call(callback::method::PROMPT, params, context) + .await?; + // A client is not trusted to have sent something this endpoint would store: + // an empty answer means different things to different stores, so it is + // refused here rather than becoming a value. + result + .validate() + .map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + Ok(result) +} + +fn parse(value: Value) -> RpcResult { + serde_json::from_value(value).map_err(|_| RpcError::new(ErrorKind::InvalidParams)) +} + +fn invalid_params(_: crate::Error) -> RpcError { + RpcError::new(ErrorKind::InvalidParams) +} + +/// Serve one typed resolution endpoint without assembling the generic adapter. +pub async fn serve_resolver( + reader: R, + writer: W, + handler: H, + config: ServerConfig, +) -> crate::Result<()> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, + H: ResolverHandler, +{ + crate::server::serve( + reader, + writer, + Arc::new(ResolverApplication::new(Arc::new(handler))), + config, + ) + .await +} diff --git a/secretspec-ipc/src/server.rs b/secretspec-ipc/src/server.rs new file mode 100644 index 000000000..13e53c1f8 --- /dev/null +++ b/secretspec-ipc/src/server.rs @@ -0,0 +1,1369 @@ +use crate::deadline::instant_from_unix_ms; +use crate::error::{ErrorKind, RpcError}; +use crate::frame::{AsyncFrameReader, write_frame}; +use crate::jsonrpc::{Envelope, Notification, Request, RequestId, Response}; +use crate::protocol::{ + CancelParams, EmptyParams, InitializeParams, InitializeResult, Limits, Product, rpc, +}; +use crate::{ABSOLUTE_MAX_FRAME_BYTES, Error, Result}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::{Mutex, Semaphore, mpsc, oneshot}; +use tokio::task::JoinSet; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +pub type RpcResult = std::result::Result; +const MAX_ABANDONED_CALLBACKS: usize = crate::MAX_IN_FLIGHT * 4; + +/// How long an out-of-band protocol response (an initialize reply, a shutdown +/// reply, or a value-free error) may wait for the writer to take it. +/// +/// Deliberately not `ServerConfig::startup_timeout`: this bounds transport +/// backpressure on a response the session owes regardless of where it is in its +/// lifecycle, whereas `startup_timeout` bounds application startup work. +const COMMIT_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone)] +pub struct RequestContext { + pub request_id: RequestId, + pub deadline: Instant, + pub cancellation: CancellationToken, + /// Calls back to the client on this same session (0.20+). A handler uses it + /// only for a method the client advertised; see [`Peer::supports`]. + pub peer: Peer, +} + +/// The client, as seen from inside a handler. +/// +/// Version 1 reverses direction for exactly one purpose: the endpoint that +/// discovers a value is missing is never the process that can ask a person for +/// it, and a stdio endpoint has no terminal to ask on. A callback is bounded by +/// the deadline and the cancellation of the request that raised it, so it can +/// neither outlive its caller nor keep an in-flight slot after the caller is +/// gone. +#[derive(Debug, Clone)] +pub struct Peer { + inner: Arc, +} + +#[derive(Debug)] +struct PeerInner { + /// Weak on purpose. The session ends by dropping its sender so the writer + /// task sees the channel close and the process can exit; a strong clone + /// here would keep that channel open and make every session linger until + /// the shutdown timeout fired. + writer: mpsc::WeakSender, + calls: Mutex, + next_id: AtomicU64, + limit: AtomicUsize, + semaphore: std::sync::RwLock>, + capabilities: std::sync::RwLock>, +} + +#[derive(Debug, Default)] +struct PeerCalls { + pending: HashMap>, + abandoned: HashSet, +} + +impl Peer { + fn new(writer: &mpsc::Sender) -> Self { + Self { + inner: Arc::new(PeerInner { + writer: writer.downgrade(), + calls: Mutex::new(PeerCalls::default()), + next_id: AtomicU64::new(1), + limit: AtomicUsize::new(ABSOLUTE_MAX_FRAME_BYTES), + semaphore: std::sync::RwLock::new(Arc::new(Semaphore::new(1))), + capabilities: std::sync::RwLock::new(HashSet::new()), + }), + } + } + + /// A peer that is not attached to a session: it advertises nothing, so + /// [`Self::supports`] is always false and no callback is ever attempted. + /// + /// This is what a handler exercised outside a live transport sees, and it + /// is the same answer a real session gives for a client that advertised no + /// callbacks, so a test never accidentally proves behavior a headless + /// consumer would not get. + pub fn detached() -> Self { + let (writer, _) = mpsc::channel(1); + Self::new(&writer) + } + + /// Whether the client advertised one callback method. A handler MUST check + /// this rather than calling and handling the failure: a client that cannot + /// reach a person wants to be told so immediately, not after a deadline. + pub fn supports(&self, method: &str) -> bool { + self.inner + .capabilities + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(method) + } + + /// Call one advertised method on the client and await its response. + /// + /// The deadline and cancellation come from the request being served, so an + /// abandoned caller takes its callback down with it and no answer can + /// arrive for a request that is already terminal. + pub async fn call( + &self, + method: &str, + params: &P, + context: &RequestContext, + ) -> RpcResult { + if context.cancellation.is_cancelled() { + return Err(RpcError::new(ErrorKind::Cancelled)); + } + if !self.supports(method) { + return Err(RpcError::new(ErrorKind::CapabilityRequired)); + } + let semaphore = self + .inner + .semaphore + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let _permit = semaphore + .try_acquire_owned() + .map_err(|_| RpcError::unavailable(None))?; + let params = + serde_json::to_value(params).map_err(|_| RpcError::new(ErrorKind::Internal))?; + let deadline_unix_ms = + crate::deadline::clamp_unix_ms(crate::deadline::unix_ms_from_instant(context.deadline)); + let id = RequestId::new(self.inner.next_id.fetch_add(1, Ordering::Relaxed)) + .map_err(|_| RpcError::new(ErrorKind::Internal))?; + let request = Request::new(id, method, deadline_unix_ms, params) + .map_err(|_| RpcError::new(ErrorKind::Internal))? + .with_parent_request_id(context.request_id); + let limit = self.inner.limit.load(Ordering::Acquire); + let payload = Zeroizing::new( + Envelope::Request(request) + .to_vec() + .map_err(|_| RpcError::new(ErrorKind::Internal))?, + ); + if payload.is_empty() || payload.len() > limit { + return Err(RpcError::new(ErrorKind::MessageTooLarge)); + } + + // The session drops its sender to close the transport. Failing to + // upgrade means the session is already ending, so there is nothing left + // to ask and nothing that could answer. + let Some(writer) = self.inner.writer.upgrade() else { + return Err(RpcError::new(ErrorKind::Unavailable)); + }; + let (sender, receiver) = oneshot::channel(); + self.inner.calls.lock().await.pending.insert(id, sender); + let command = WriterCommand { + payload, + limit, + committed: None, + }; + let queued = tokio::select! { + biased; + _ = context.cancellation.cancelled() => { + self.inner.calls.lock().await.pending.remove(&id); + return Err(RpcError::new(ErrorKind::Cancelled)); + } + queued = writer.send(command) => queued, + }; + if queued.is_err() { + self.inner.calls.lock().await.pending.remove(&id); + return Err(RpcError::new(ErrorKind::Unavailable)); + } + + let response = tokio::select! { + biased; + _ = context.cancellation.cancelled() => { + self.abandon_call(id).await; + return Err(RpcError::new(ErrorKind::Cancelled)); + } + response = tokio::time::timeout_at(context.deadline, receiver) => response, + }; + match response { + Ok(Ok(Response::Success(response))) => serde_json::from_value(response.result) + .map_err(|_| RpcError::new(ErrorKind::OperationFailed)), + Ok(Ok(Response::Error(response))) => Err(response.error), + Ok(Err(_)) => Err(RpcError::new(ErrorKind::Unavailable)), + Err(_) => { + self.abandon_call(id).await; + Err(RpcError::new(ErrorKind::DeadlineExceeded)) + } + } + } + + /// Deliver one inbound response, reporting whether it matched a call this + /// side actually made. An unmatched response is a protocol violation. + async fn deliver(&self, response: Response) -> bool { + let Some(id) = response.id() else { + return false; + }; + let mut calls = self.inner.calls.lock().await; + match calls.pending.remove(&id) { + Some(sender) => { + calls.abandoned.remove(&id); + let _ = sender.send(response); + true + } + None => calls.abandoned.remove(&id), + } + } + + async fn abandon_call(&self, id: RequestId) { + // Record first, then remove from pending. `deliver` removes the marker + // when a response wins the race while the sender is still present. + let mut calls = self.inner.calls.lock().await; + if calls.abandoned.len() < MAX_ABANDONED_CALLBACKS { + calls.abandoned.insert(id); + } + calls.pending.remove(&id); + } + + async fn fail_all(&self) { + let mut calls = self.inner.calls.lock().await; + calls.pending.clear(); + calls.abandoned.clear(); + } +} + +/// Transport-independent application hook. Typed resolution and provider +/// adapters below this layer own application parameter validation. +#[async_trait] +pub trait ApplicationHandler: Send + Sync + 'static { + fn protocol(&self) -> &'static str; + fn versions(&self) -> &'static [u32] { + &[1] + } + fn capabilities(&self) -> Vec; + + fn validate_capabilities(&self, _capabilities: &[String]) -> RpcResult<()> { + Ok(()) + } + + async fn initialize(&self, context: &RequestContext, application: Value) -> RpcResult; + + async fn call(&self, context: RequestContext, method: &str, params: Value) -> RpcResult; + + /// Reports whether the application's response became the terminal writer + /// outcome. Handlers use an uncommitted outcome to release resources that + /// were created while producing a response (for example resolver leases). + async fn request_finished(&self, _request_id: RequestId, _committed: bool) {} + + async fn shutdown(&self) {} +} + +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub product: Product, + pub limits: Limits, + pub startup_timeout: Duration, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + product: Product { + name: "secretspec-ipc".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + limits: Limits { + max_frame_bytes: ABSOLUTE_MAX_FRAME_BYTES, + max_in_flight: 8, + }, + startup_timeout: Duration::from_secs(5), + } + } +} + +/// One already-serialized frame for the single writer task. +/// +/// The payload is built by the sender rather than the writer because the +/// too-large fallback has to inspect the encoded size before it can decide what +/// to enqueue, and because the writer now carries outbound callback requests as +/// well as responses. +struct WriterCommand { + payload: Zeroizing>, + limit: usize, + committed: Option>>, +} + +/// Serve exactly one application session on a private byte stream. +/// +/// Before application initialization, a peer may issue side-effect-free +/// `rpc.discover` requests. No application method is dispatched until +/// `rpc.initialize` succeeds. +pub async fn serve( + reader: R, + mut writer: W, + handler: Arc, + config: ServerConfig, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + H: ApplicationHandler, +{ + config.product.validate()?; + config.limits.validate()?; + let mut reader = AsyncFrameReader::new(reader); + + let (writer_tx, mut writer_rx) = + mpsc::channel::(config.limits.max_in_flight + 2); + let disconnected = CancellationToken::new(); + let writer_disconnected = disconnected.clone(); + let mut writer_task = tokio::spawn(async move { + while let Some(command) = writer_rx.recv().await { + let outcome = write_frame(&mut writer, &command.payload, command.limit) + .await + .map_err(|_| ()); + if let Some(committed) = command.committed { + let _ = committed.send(outcome); + } + if outcome.is_err() { + writer_disconnected.cancel(); + break; + } + } + use tokio::io::AsyncWriteExt; + let _ = writer.shutdown().await; + }); + + let peer = Peer::new(&writer_tx); + let mut active_limit = ABSOLUTE_MAX_FRAME_BYTES; + let mut initialized = false; + let mut advertised_capabilities = HashSet::new(); + let mut last_seen_id: Option = None; + let inflight: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let mut tasks = JoinSet::new(); + // Replaced with the negotiated permit count during initialization; no + // application call can be dispatched before that happens. + let mut semaphore = Arc::new(Semaphore::new(1)); + let mut shutting_down = false; + // A transport or protocol failure must still cancel in-flight work, join + // its tasks, and run `handler.shutdown()`. Returning `?` straight out of + // the loop would skip all of that, so the failure is carried out instead. + let mut fatal: Option = None; + + loop { + let frame = tokio::select! { + _ = disconnected.cancelled() => break, + frame = reader.read_frame(active_limit) => match frame { + Ok(frame) => frame, + Err(error) => { + fatal = Some(error); + break; + } + }, + }; + let Some(frame) = frame else { + break; + }; + + let envelope = match Envelope::parse_classified(&frame) { + Ok(envelope) => envelope, + Err((_, kind)) => { + // The strict parser intentionally does not recover an ID from a + // malformed object. Emit one value-free error, then close. The + // parser reports which layer rejected the frame, so there is no + // need to re-parse it here to choose a kind. + let error = Response::error(None, RpcError::new(kind)); + let _ = commit(&writer_tx, error, active_limit).await; + break; + } + }; + + match envelope { + // Before this session reversed direction, any response was a + // protocol violation. It still is unless it answers a callback this + // side actually made: an unmatched or duplicate response means the + // peer is tracking a different session state than we are. + Envelope::Response(response) => { + if !initialized || !peer.deliver(response).await { + break; + } + } + Envelope::Notification(notification) => { + handle_notification(notification, &inflight).await; + } + Envelope::Request(request) => { + if last_seen_id.is_some_and(|last| request.id <= last) { + let response = + Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidRequest)); + let _ = commit(&writer_tx, response, active_limit).await; + break; + } + last_seen_id = Some(request.id); + + if !initialized { + if request.method == rpc::DISCOVER { + if let Err(error) = discover( + &request, + handler.as_ref(), + &config, + &writer_tx, + active_limit, + ) + .await + { + fatal = Some(error); + break; + } + continue; + } + if request.method != rpc::INITIALIZE { + let response = Response::error( + Some(request.id), + RpcError::new(ErrorKind::InvalidRequest), + ); + let _ = commit(&writer_tx, response, active_limit).await; + break; + } + let selected = match initialize( + &request, + handler.as_ref(), + &config, + &writer_tx, + &peer, + &mut reader, + ) + .await + { + Ok(selected) => selected, + Err(error) => { + fatal = Some(error); + break; + } + }; + let Some((limits, capabilities)) = selected else { + break; + }; + peer.inner + .limit + .store(limits.max_frame_bytes, Ordering::Release); + *peer + .inner + .semaphore + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Arc::new(Semaphore::new(limits.max_in_flight)); + active_limit = limits.max_frame_bytes; + semaphore = Arc::new(Semaphore::new(limits.max_in_flight)); + advertised_capabilities = capabilities.into_iter().collect(); + initialized = true; + continue; + } + + if request.method == rpc::INITIALIZE { + let response = + Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidRequest)); + let _ = commit(&writer_tx, response, active_limit).await; + break; + } + + if request.method == rpc::DISCOVER { + if let Err(error) = discover( + &request, + handler.as_ref(), + &config, + &writer_tx, + active_limit, + ) + .await + { + fatal = Some(error); + break; + } + continue; + } + + if request.method == rpc::SHUTDOWN { + if shutdown( + request, + &inflight, + &mut tasks, + handler.as_ref(), + &writer_tx, + active_limit, + ) + .await + .is_err() + { + break; + } + shutting_down = true; + break; + } + + if shutting_down { + let response = Response::error(Some(request.id), RpcError::unavailable(None)); + let _ = commit(&writer_tx, response, active_limit).await; + continue; + } + + if !request.method.starts_with("resolver.") + && !request.method.starts_with("provider.") + { + let response = + Response::error(Some(request.id), RpcError::new(ErrorKind::MethodNotFound)); + let _ = commit(&writer_tx, response, active_limit).await; + continue; + } + if !advertised_capabilities.contains(&request.method) { + let response = Response::error( + Some(request.id), + RpcError::new(ErrorKind::CapabilityRequired), + ); + let _ = commit(&writer_tx, response, active_limit).await; + continue; + } + + let deadline = request_deadline(&request); + if deadline <= Instant::now() { + let response = Response::error( + Some(request.id), + RpcError::new(ErrorKind::DeadlineExceeded), + ); + let _ = commit(&writer_tx, response, active_limit).await; + continue; + } + + let permit = match semaphore.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + let response = + Response::error(Some(request.id), RpcError::unavailable(None)); + let _ = commit(&writer_tx, response, active_limit).await; + continue; + } + }; + + let cancellation = CancellationToken::new(); + inflight + .lock() + .await + .insert(request.id, cancellation.clone()); + let context = RequestContext { + request_id: request.id, + deadline, + cancellation: cancellation.clone(), + peer: peer.clone(), + }; + let task_handler = handler.clone(); + let task_writer = writer_tx.clone(); + let task_inflight = inflight.clone(); + let task_disconnected = disconnected.clone(); + tasks.spawn(async move { + let _permit = permit; + run_call( + request, + context, + task_handler, + task_writer, + task_inflight, + task_disconnected, + active_limit, + ) + .await; + }); + } + } + } + + // Draining preserves accepted work. Only an expired shutdown deadline + // turns this into cancellation; EOF remains the immediate-abort path. + // A callback still waiting on a client that is gone would otherwise hold + // its handler, and therefore its request, until the deadline. + peer.fail_all().await; + if !shutting_down { + tasks.abort_all(); + } + while tasks.join_next().await.is_some() {} + if initialized && !shutting_down { + let _ = tokio::time::timeout(config.startup_timeout, handler.shutdown()).await; + } + drop(writer_tx); + if tokio::time::timeout(config.startup_timeout, &mut writer_task) + .await + .is_err() + { + writer_task.abort(); + let _ = writer_task.await; + } + match fatal { + Some(error) => Err(error), + None => Ok(()), + } +} + +async fn discover( + request: &Request, + handler: &H, + config: &ServerConfig, + writer: &mpsc::Sender, + limit: usize, +) -> Result<()> { + let _: EmptyParams = match serde_json::from_value(request.params.clone()) { + Ok(params) => params, + Err(_) => { + return commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidParams)), + limit, + ) + .await; + } + }; + if request_deadline(request) <= Instant::now() { + return commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::DeadlineExceeded)), + limit, + ) + .await; + } + + let methods = handler.capabilities(); + if crate::protocol::validate_capabilities(&methods).is_err() + || handler.validate_capabilities(&methods).is_err() + { + return commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::Internal)), + limit, + ) + .await; + } + let document = crate::description::openrpc( + handler.protocol(), + handler.versions(), + &config.product, + &methods, + )?; + commit(writer, Response::success(request.id, document), limit).await +} + +async fn initialize( + request: &Request, + handler: &H, + config: &ServerConfig, + writer: &mpsc::Sender, + peer: &Peer, + reader: &mut AsyncFrameReader, +) -> Result)>> +where + R: AsyncRead + Unpin, +{ + let params: InitializeParams = match serde_json::from_value(request.params.clone()) { + Ok(params) => params, + Err(_) => { + let response = + Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidParams)); + commit(writer, response, ABSOLUTE_MAX_FRAME_BYTES).await?; + return Ok(None); + } + }; + if params.protocol != handler.protocol() { + let response = Response::error( + Some(request.id), + RpcError::new(ErrorKind::UnsupportedVersion), + ); + commit(writer, response, ABSOLUTE_MAX_FRAME_BYTES).await?; + return Ok(None); + } + if params.validate_common(handler.protocol()).is_err() { + let response = Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidParams)); + commit(writer, response, ABSOLUTE_MAX_FRAME_BYTES).await?; + return Ok(None); + } + let Some(version) = params + .versions + .iter() + .copied() + .filter(|version| handler.versions().contains(version)) + .max() + else { + let response = Response::error( + Some(request.id), + RpcError::new(ErrorKind::UnsupportedVersion), + ); + commit(writer, response, ABSOLUTE_MAX_FRAME_BYTES).await?; + return Ok(None); + }; + + let capabilities = handler.capabilities(); + if crate::protocol::validate_capabilities(&capabilities).is_err() { + let response = Response::error(Some(request.id), RpcError::new(ErrorKind::Internal)); + commit(writer, response, ABSOLUTE_MAX_FRAME_BYTES).await?; + return Ok(None); + } + if let Err(error) = handler.validate_capabilities(&capabilities) { + commit( + writer, + Response::error(Some(request.id), error), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + let limits = config.limits.select(params.limits)?; + // Recorded before the application handler runs, so an initialize handler + // that needs to ask the client something can already see what it answers. + // Callbacks the client did not advertise are simply never sent. + *peer + .inner + .capabilities + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + params.client_methods.iter().cloned().collect(); + let cancellation = CancellationToken::new(); + let context = RequestContext { + request_id: request.id, + deadline: request_deadline(request).min(Instant::now() + config.startup_timeout), + cancellation, + peer: peer.clone(), + }; + if context.deadline <= Instant::now() { + commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::DeadlineExceeded)), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + // Initialization may itself call the client (for example to request a + // provider credential). Keep consuming that one transport while the + // application future is pending; otherwise the callback response sits in + // the pipe behind a read loop that cannot resume until initialization has + // completed, producing a deadline deadlock. + let mut operation = Box::pin(handler.initialize(&context, params.application)); + let application = loop { + let outcome = tokio::select! { + biased; + _ = tokio::time::sleep_until(context.deadline) => { + context.cancellation.cancel(); + commit( + writer, + Response::error( + Some(request.id), + RpcError::new(ErrorKind::DeadlineExceeded), + ), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + _ = context.cancellation.cancelled() => { + commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::Cancelled)), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + outcome = &mut operation => Some(outcome), + frame = reader.read_frame(ABSOLUTE_MAX_FRAME_BYTES) => { + let Some(frame) = frame? else { + context.cancellation.cancel(); + return Err(Error::Closed); + }; + match Envelope::parse_classified(&frame) { + Ok(Envelope::Response(response)) => { + if !peer.deliver(response).await { + context.cancellation.cancel(); + return Err(Error::Protocol( + "unmatched callback response during initialization", + )); + } + None + } + Ok(Envelope::Notification(notification)) => { + if notification.method == rpc::CANCEL + && serde_json::from_value::(notification.params) + .is_ok_and(|params| params.id == request.id) + { + context.cancellation.cancel(); + } + None + } + Ok(Envelope::Request(invalid)) => { + context.cancellation.cancel(); + commit( + writer, + Response::error( + Some(invalid.id), + RpcError::new(ErrorKind::InvalidRequest), + ), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + Err((_, kind)) => { + context.cancellation.cancel(); + commit( + writer, + Response::error(None, RpcError::new(kind)), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + } + } + }; + let Some(outcome) = outcome else { continue }; + break match outcome { + Ok(application) => application, + Err(error) => { + // Stop every initialize-time callback before making its + // parent terminal on the wire. + context.cancellation.cancel(); + commit( + writer, + Response::error(Some(request.id), error), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + return Ok(None); + } + }; + }; + let result = InitializeResult { + protocol: handler.protocol().to_string(), + version, + server: config.product.clone(), + methods: capabilities.clone(), + capabilities: Default::default(), + limits, + application, + }; + let value = match serde_json::to_value(result) { + Ok(value) => value, + Err(_) => { + context.cancellation.cancel(); + return Err(Error::Protocol("serialize initialize result")); + } + }; + // Negotiated limits take effect only after this response commits. Cancel + // initialize-time callbacks first so none can cross that boundary. + context.cancellation.cancel(); + commit( + writer, + Response::success(request.id, value), + ABSOLUTE_MAX_FRAME_BYTES, + ) + .await?; + Ok(Some((limits, capabilities))) +} + +async fn handle_notification( + notification: Notification, + inflight: &Arc>>, +) { + if notification.method != rpc::CANCEL { + return; + } + let Ok(params) = serde_json::from_value::(notification.params) else { + return; + }; + if let Some(cancellation) = inflight.lock().await.get(¶ms.id) { + cancellation.cancel(); + } +} + +async fn run_call( + request: Request, + context: RequestContext, + handler: Arc, + writer: mpsc::Sender, + inflight: Arc>>, + disconnected: CancellationToken, + limit: usize, +) { + let mut operation = Box::pin(handler.call(context.clone(), &request.method, request.params)); + let response = tokio::select! { + biased; + _ = tokio::time::sleep_until(context.deadline) => { + context.cancellation.cancel(); + let response = Response::error(Some(request.id), RpcError::new(ErrorKind::DeadlineExceeded)); + let _ = send_terminal(&writer, response, limit).await; + let _ = operation.await; + handler.request_finished(request.id, false).await; + inflight.lock().await.remove(&request.id); + return; + } + _ = context.cancellation.cancelled() => { + let response = Response::error(Some(request.id), RpcError::new(ErrorKind::Cancelled)); + let _ = send_terminal(&writer, response, limit).await; + // Keep the semaphore permit until a non-cooperative handler really + // exits. A late outcome is deliberately discarded. + let _ = operation.await; + handler.request_finished(request.id, false).await; + inflight.lock().await.remove(&request.id); + return; + } + result = &mut operation => response_from_result(request.id, result), + }; + let committed = commit_application_before( + &writer, + response, + limit, + context.deadline, + &context.cancellation, + &disconnected, + ) + .await; + handler.request_finished(request.id, committed).await; + inflight.lock().await.remove(&request.id); +} + +/// Deadline and cancellation are themselves terminal outcomes. The channel is +/// sized above the negotiated in-flight count, so enqueueing one response per +/// accepted request remains bounded even when the transport is backpressured. +async fn send_terminal( + writer: &mpsc::Sender, + response: Response, + limit: usize, +) -> Result<()> { + let payload = encode_response(&response, limit)?; + writer + .send(WriterCommand { + payload, + limit, + committed: None, + }) + .await + .map_err(|_| Error::Closed) +} + +/// Serialize a response, substituting `message_too_large` for one that cannot +/// fit. A result carrying a secret must never be truncated onto the wire, and +/// the caller is owed exactly one terminal frame either way. +fn encode_response(response: &Response, limit: usize) -> Result>> { + let payload = serde_json::to_vec(response) + .map_err(|_| Error::Protocol("failed to serialize response"))?; + if !payload.is_empty() && payload.len() <= limit { + return Ok(Zeroizing::new(payload)); + } + drop(Zeroizing::new(payload)); + let replacement = Response::error(response.id(), RpcError::new(ErrorKind::MessageTooLarge)); + serde_json::to_vec(&replacement) + .map(Zeroizing::new) + .map_err(|_| Error::Protocol("failed to serialize response")) +} + +fn response_from_result(id: RequestId, result: RpcResult) -> Response { + match result { + Ok(value) => Response::success(id, value), + Err(error) => Response::error(Some(id), error), + } +} + +async fn commit_application_before( + writer: &mpsc::Sender, + response: Response, + limit: usize, + deadline: Instant, + cancellation: &CancellationToken, + disconnected: &CancellationToken, +) -> bool { + let encoded = serde_json::to_vec(&response).map(Zeroizing::new).ok(); + let application_response = encoded + .as_ref() + .is_some_and(|payload| !payload.is_empty() && payload.len() <= limit); + let request_id = response.id(); + let payload = match encoded.filter(|_| application_response) { + Some(payload) => payload, + None => { + let replacement = + Response::error(request_id, RpcError::new(ErrorKind::MessageTooLarge)); + match serde_json::to_vec(&replacement).map(Zeroizing::new) { + Ok(payload) => payload, + Err(_) => return false, + } + } + }; + let (committed_tx, committed_rx) = oneshot::channel(); + let command = WriterCommand { + payload, + limit, + committed: Some(committed_tx), + }; + + // Only cancellation/deadline that wins before enqueue may replace the + // application response. Once the writer owns a frame, it is the sole + // terminal outcome; a later write timeout closes the session instead of + // queueing a second response. + let enqueued = tokio::select! { + biased; + _ = tokio::time::sleep_until(deadline) => { + cancellation.cancel(); + let response = Response::error(request_id, RpcError::new(ErrorKind::DeadlineExceeded)); + let _ = send_terminal(writer, response, limit).await; + false + } + _ = cancellation.cancelled() => { + let response = Response::error(request_id, RpcError::new(ErrorKind::Cancelled)); + let _ = send_terminal(writer, response, limit).await; + false + } + result = writer.send(command) => result.is_ok(), + }; + if !enqueued { + return false; + } + + // The terminal response now precedes any later writer command. End the + // child callback lifetime before waiting for the bytes to commit so no + // detached callback can be queued behind its parent response. + cancellation.cancel(); + + match tokio::time::timeout_at(deadline, committed_rx).await { + Ok(Ok(Ok(()))) => application_response, + Ok(Ok(Err(()))) | Ok(Err(_)) | Err(_) => { + disconnected.cancel(); + false + } + } +} + +async fn commit( + writer: &mpsc::Sender, + response: Response, + limit: usize, +) -> Result<()> { + let payload = encode_response(&response, limit)?; + tokio::time::timeout(COMMIT_TIMEOUT, async { + let (committed_tx, committed_rx) = oneshot::channel(); + writer + .send(WriterCommand { + payload, + limit, + committed: Some(committed_tx), + }) + .await + .map_err(|_| Error::Closed)?; + committed_rx + .await + .map_err(|_| Error::Closed)? + .map_err(|_| Error::Closed) + }) + .await + .map_err(|_| Error::DeadlineExceeded)? +} + +fn request_deadline(request: &Request) -> Instant { + instant_from_unix_ms(request.deadline_unix_ms()) +} + +async fn shutdown( + request: Request, + inflight: &Arc>>, + tasks: &mut JoinSet<()>, + handler: &H, + writer: &mpsc::Sender, + limit: usize, +) -> Result<()> { + let deadline = request_deadline(&request); + let _: EmptyParams = match serde_json::from_value(request.params) { + Ok(params) => params, + Err(_) => { + commit( + writer, + Response::error(Some(request.id), RpcError::new(ErrorKind::InvalidParams)), + limit, + ) + .await?; + return Err(Error::Protocol("invalid shutdown")); + } + }; + for cancellation in inflight.lock().await.values() { + cancellation.cancel(); + } + let drain = async { + while tasks.join_next().await.is_some() {} + handler.shutdown().await; + }; + if tokio::time::timeout_at(deadline, drain).await.is_err() { + for cancellation in inflight.lock().await.values() { + cancellation.cancel(); + } + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + } + commit(writer, Response::success(request.id, json!({})), limit).await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The session ends by dropping its sender so the writer task observes the + /// close and the process can exit. Holding a strong clone in the peer made + /// every session linger until the shutdown timeout instead, which showed up + /// as an endpoint that never exited after EOF. + #[tokio::test] + async fn a_peer_does_not_keep_the_writer_channel_open() { + let (writer_tx, mut writer_rx) = mpsc::channel::(1); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + drop(writer_tx); + assert!( + writer_rx.recv().await.is_none(), + "the peer kept the writer channel open after the session dropped its sender" + ); + + // A callback attempted on a session that is already ending is reported + // as unavailable rather than waiting for a writer that will never run. + let context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: Instant::now() + Duration::from_secs(30), + cancellation: CancellationToken::new(), + peer: peer.clone(), + }; + let error = peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await + .unwrap_err(); + assert_eq!(error.data.kind, ErrorKind::Unavailable); + } + + #[tokio::test] + async fn callbacks_obey_the_pre_negotiation_outbound_limit() { + let (writer_tx, mut writer_rx) = mpsc::channel::(4); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + let cancellation = CancellationToken::new(); + let context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: Instant::now() + Duration::from_secs(2), + cancellation: cancellation.clone(), + peer: peer.clone(), + }; + + let first_peer = peer.clone(); + let first_context = context.clone(); + let first = tokio::spawn(async move { + first_peer + .call::<_, Value>("client.prompt", &json!({}), &first_context) + .await + }); + let _first_frame = writer_rx.recv().await.expect("first callback frame"); + + let second = peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await; + assert!(matches!( + second, + Err(ref error) if error.data.kind == ErrorKind::Unavailable + )); + assert!(writer_rx.try_recv().is_err(), "a second callback was sent"); + + cancellation.cancel(); + assert!(matches!( + first.await.unwrap(), + Err(ref error) if error.data.kind == ErrorKind::Cancelled + )); + } + + #[tokio::test] + async fn pre_negotiation_callbacks_use_the_absolute_frame_limit() { + let (writer_tx, mut writer_rx) = mpsc::channel::(2); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + let context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: Instant::now() + Duration::from_secs(2), + cancellation: CancellationToken::new(), + peer: peer.clone(), + }; + + let outcome = peer + .call::<_, Value>( + "client.prompt", + &json!({"value": "x".repeat(ABSOLUTE_MAX_FRAME_BYTES)}), + &context, + ) + .await; + assert!(matches!( + outcome, + Err(ref error) if error.data.kind == ErrorKind::MessageTooLarge + )); + assert!(writer_rx.try_recv().is_err(), "oversized callback was sent"); + } + + #[tokio::test] + async fn callbacks_switch_to_the_negotiated_outbound_limit() { + let (writer_tx, mut writer_rx) = mpsc::channel::(6); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + *peer.inner.semaphore.write().unwrap() = Arc::new(Semaphore::new(2)); + peer.inner.limit.store(32 * 1024, Ordering::Release); + let cancellation = CancellationToken::new(); + let context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: Instant::now() + Duration::from_secs(2), + cancellation: cancellation.clone(), + peer: peer.clone(), + }; + + let mut calls = Vec::new(); + for _ in 0..2 { + let peer = peer.clone(); + let context = context.clone(); + calls.push(tokio::spawn(async move { + peer.call::<_, Value>("client.prompt", &json!({}), &context) + .await + })); + writer_rx.recv().await.expect("negotiated callback frame"); + } + let third = peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await; + assert!(matches!( + third, + Err(ref error) if error.data.kind == ErrorKind::Unavailable + )); + + cancellation.cancel(); + for call in calls { + assert!(matches!( + call.await.unwrap(), + Err(ref error) if error.data.kind == ErrorKind::Cancelled + )); + } + } + + #[tokio::test] + async fn a_callback_cannot_start_after_its_parent_is_terminal() { + let (writer_tx, mut writer_rx) = mpsc::channel::(2); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: Instant::now() + Duration::from_secs(2), + cancellation, + peer: peer.clone(), + }; + + let outcome = peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await; + assert!(matches!( + outcome, + Err(ref error) if error.data.kind == ErrorKind::Cancelled + )); + assert!(writer_rx.try_recv().is_err(), "late callback was sent"); + } + + // Most dispatcher behavior is exercised through the black-box integration + // tests; keep this module focused on wall-to-monotonic conversion. + #[test] + fn expired_deadline_is_not_started() { + let request = Request::new(RequestId::new(1).unwrap(), "test.call", 1, json!({})).unwrap(); + assert!(request_deadline(&request) <= Instant::now()); + } + + #[tokio::test] + async fn an_expired_callback_consumes_its_terminal_response() { + let (writer_tx, mut writer_rx) = mpsc::channel::(1); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + + // More than one in-flight window proves each consumed response retires + // its abandoned marker instead of slowly filling the bounded set. + for raw_id in 1..=64 { + let context = RequestContext { + request_id: RequestId::new(10).unwrap(), + deadline: Instant::now(), + cancellation: CancellationToken::new(), + peer: peer.clone(), + }; + let error = peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await + .unwrap_err(); + assert_eq!(error.data.kind, ErrorKind::DeadlineExceeded); + let _request = writer_rx.recv().await.unwrap(); + + let id = RequestId::new(raw_id).unwrap(); + let terminal = Response::error(Some(id), RpcError::new(ErrorKind::DeadlineExceeded)); + assert!(peer.deliver(terminal.clone()).await); + assert!(!peer.deliver(terminal).await, "duplicates remain invalid"); + } + assert!(peer.inner.calls.lock().await.abandoned.is_empty()); + } + + #[tokio::test] + async fn a_cancelled_callback_also_consumes_its_terminal_response() { + let (writer_tx, mut writer_rx) = mpsc::channel::(1); + let peer = Peer::new(&writer_tx); + peer.inner + .capabilities + .write() + .unwrap() + .insert("client.prompt".to_string()); + let cancellation = CancellationToken::new(); + let context = RequestContext { + request_id: RequestId::new(10).unwrap(), + deadline: Instant::now() + Duration::from_secs(1), + cancellation: cancellation.clone(), + peer: peer.clone(), + }; + + let call_peer = peer.clone(); + let call = tokio::spawn(async move { + call_peer + .call::<_, Value>("client.prompt", &json!({}), &context) + .await + }); + let _request = writer_rx.recv().await.unwrap(); + cancellation.cancel(); + let error = call.await.unwrap().unwrap_err(); + assert_eq!(error.data.kind, ErrorKind::Cancelled); + let terminal = Response::error( + Some(RequestId::new(1).unwrap()), + RpcError::new(ErrorKind::Cancelled), + ); + assert!(peer.deliver(terminal).await); + assert!(peer.inner.calls.lock().await.abandoned.is_empty()); + } +} diff --git a/secretspec-ipc/tests/fixtures.rs b/secretspec-ipc/tests/fixtures.rs new file mode 100644 index 000000000..3cf854cc3 --- /dev/null +++ b/secretspec-ipc/tests/fixtures.rs @@ -0,0 +1,285 @@ +use proptest::prelude::*; +use secretspec_ipc::frame::{FrameDecoder, encode}; +use secretspec_ipc::jsonrpc::Envelope; +use serde_json::{Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn schema_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../schema/ipc/v1") +} + +fn packaged_schema_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("schema/ipc/v1") +} + +#[test] +fn schemas_openrpc_and_fixtures_are_valid_json() { + let root = schema_root(); + let schemas = [ + "common.schema.json", + "resolver.schema.json", + "provider.schema.json", + ] + .map(|name| { + let bytes = fs::read(root.join(name)).unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert!(value.is_object(), "{name}"); + (name, value) + }); + for name in ["resolver.openrpc.json", "provider.openrpc.json"] { + let bytes = fs::read(root.join(name)).unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert!(value.is_object(), "{name}"); + } + + let registry = schemas + .iter() + .fold(jsonschema::Registry::new(), |registry, (_, schema)| { + let uri = schema["$id"].as_str().expect("schema has an absolute $id"); + registry.add(uri, schema).expect("schema resource is valid") + }) + .prepare() + .expect("schema registry resolves every reference"); + + for role in ["wire", "resolver", "provider"] { + for entry in fs::read_dir(root.join("fixtures").join(role)).unwrap() { + let path = entry.unwrap().path(); + let bytes = fs::read(&path).unwrap(); + Envelope::parse(&bytes).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + let envelope: Value = serde_json::from_slice(&bytes).unwrap(); + if envelope.get("method").is_some() && envelope.get("id").is_some() { + let request_schema = json!({ + "$ref": concat!( + "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "#/$defs/RequestEnvelope" + ) + }); + let validator = jsonschema::options() + .with_registry(®istry) + .build(&request_schema) + .unwrap(); + validator.validate(&envelope).unwrap_or_else(|error| { + panic!("{} is not a request envelope: {error}", path.display()) + }); + } + let (schema_ref, instance) = fixture_schema(role, &path, &envelope); + let root_schema = json!({ "$ref": schema_ref }); + let validator = jsonschema::options() + .with_registry(®istry) + .build(&root_schema) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + if let Err(error) = validator.validate(instance) { + panic!("{} does not match {schema_ref}: {error}", path.display()); + } + } + } +} + +#[test] +fn embedded_discovery_documents_match_the_canonical_assets() { + let canonical = schema_root(); + let packaged = packaged_schema_root(); + for name in [ + "common.schema.json", + "resolver.schema.json", + "provider.schema.json", + "resolver.openrpc.json", + "provider.openrpc.json", + ] { + assert_eq!( + fs::read(canonical.join(name)).unwrap(), + fs::read(packaged.join(name)).unwrap(), + "embedded discovery asset drifted: {name}" + ); + } +} + +#[test] +fn method_catalogs_match_openrpc() { + let root = schema_root(); + let cases = [ + ( + "resolver.openrpc.json", + "resolver.", + // The full catalog rather than the advertised set: the document + // describes the optional mutation methods too. + secretspec_ipc::protocol::resolver::method::ALL, + ), + ( + "provider.openrpc.json", + "provider.", + secretspec_ipc::protocol::provider::method::ALL, + ), + ( + "resolver.openrpc.json", + "rpc.", + secretspec_ipc::protocol::rpc::ALL, + ), + ( + "provider.openrpc.json", + "rpc.", + secretspec_ipc::protocol::rpc::ALL, + ), + // The callbacks the endpoint sends the other way. Documented in the + // resolver's own OpenRPC document because that is the protocol they + // belong to, and pinned here so the two catalogs cannot drift. + ( + "resolver.openrpc.json", + "client.", + secretspec_ipc::protocol::callback::method::RESOLVER, + ), + ( + "provider.openrpc.json", + "client.", + secretspec_ipc::protocol::callback::method::PROVIDER, + ), + ]; + for (document, prefix, catalog) in cases { + let value: Value = serde_json::from_slice(&fs::read(root.join(document)).unwrap()).unwrap(); + let mut documented: Vec<_> = value["methods"] + .as_array() + .unwrap() + .iter() + .filter_map(|method| method["name"].as_str()) + .filter(|name| name.starts_with(prefix)) + .collect(); + let mut implemented = catalog.to_vec(); + documented.sort_unstable(); + implemented.sort_unstable(); + assert_eq!(implemented, documented, "{document}"); + } +} + +fn fixture_schema<'a>(role: &str, path: &Path, envelope: &'a Value) -> (&'static str, &'a Value) { + let name = path.file_name().and_then(|name| name.to_str()).unwrap(); + match (role, name) { + ("wire", "error.json") => ( + "https://secretspec.dev/schema/ipc/v1/common.schema.json#/$defs/ErrorResponseEnvelope", + envelope, + ), + ("wire", "discovery-result.json") => ( + concat!( + "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "#/$defs/DiscoveryDocument" + ), + &envelope["result"], + ), + ("wire", "cancel.json") => ( + concat!( + "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "#/$defs/CancelParams" + ), + &envelope["params"], + ), + ("wire", "discover.json") => ( + concat!( + "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "#/$defs/EmptyParams" + ), + &envelope["params"], + ), + ("wire", "shutdown.json") => ( + concat!( + "https://secretspec.dev/schema/ipc/v1/common.schema.json", + "#/$defs/EmptyParams" + ), + &envelope["params"], + ), + ("resolver", "initialize-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/InitializeParams", + &envelope["params"], + ), + ("resolver", "initialize-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/InitializeResult", + &envelope["result"], + ), + ("resolver", "get-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/GetParams", + &envelope["params"], + ), + ("resolver", "get-value-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/GetResult", + &envelope["result"], + ), + ("resolver", "release-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/ReleaseParams", + &envelope["params"], + ), + ("resolver", "set-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/SetParams", + &envelope["params"], + ), + ("resolver", "set-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/SetResult", + &envelope["result"], + ), + ("resolver", "prompt-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/PromptParams", + &envelope["params"], + ), + ("resolver", "prompt-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/PromptResult", + &envelope["result"], + ), + ("resolver", "delete-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/DeleteParams", + &envelope["params"], + ), + ("resolver", "delete-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/resolver.schema.json#/$defs/DeleteResult", + &envelope["result"], + ), + ("provider", "initialize-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/InitializeParams", + &envelope["params"], + ), + ("provider", "initialize-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/InitializeResult", + &envelope["result"], + ), + ("provider", "resolve-address-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/AddressParams", + &envelope["params"], + ), + ("provider", "get-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/AddressParams", + &envelope["params"], + ), + ("provider", "get-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/GetResult", + &envelope["result"], + ), + ("provider", "credential-request.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/CredentialParams", + &envelope["params"], + ), + ("provider", "credential-result.json") => ( + "https://secretspec.dev/schema/ipc/v1/provider.schema.json#/$defs/CredentialResult", + &envelope["result"], + ), + _ => panic!("fixture {role}/{name} has no schema assertion"), + } +} + +proptest! { + #[test] + fn every_chunking_round_trips(payload in "\\{\\\"[a-z]{0,64}\\\":([0-9]{1,6}|true|null)\\}", chunks in prop::collection::vec(1usize..16, 1..32)) { + let frame = encode(payload.as_bytes(), 4096).unwrap(); + let mut decoder = FrameDecoder::new(4096).unwrap(); + let mut offset = 0; + let mut output = Vec::new(); + for size in chunks { + if offset == frame.len() { break; } + let end = (offset + size).min(frame.len()); + output.extend(decoder.push(&frame[offset..end]).unwrap()); + offset = end; + } + if offset < frame.len() { + output.extend(decoder.push(&frame[offset..]).unwrap()); + } + decoder.finish_eof().unwrap(); + prop_assert_eq!(output.len(), 1); + prop_assert_eq!(output[0].as_slice(), payload.as_bytes()); + } +} diff --git a/secretspec-ipc/tests/provider_session.rs b/secretspec-ipc/tests/provider_session.rs new file mode 100644 index 000000000..3bc43c277 --- /dev/null +++ b/secretspec-ipc/tests/provider_session.rs @@ -0,0 +1,381 @@ +use async_trait::async_trait; +use secretspec_ipc::client::{CallbackHandler, Client}; +use secretspec_ipc::error::{ErrorKind, RpcError}; +use secretspec_ipc::protocol::callback::{self, CredentialParams, CredentialResult}; +use secretspec_ipc::protocol::provider::{ + self as wire, Address, AddressParams, ApplicationContext, GetResult, InitializeApplication, + InitializedApplication, Metadata, Persistence, ReflectParams, ReflectResult, + ResolveAddressResult, SetParams, +}; +use secretspec_ipc::protocol::{ + InitializeParams, Limits, PROTOCOL_VERSION, PROVIDER_PROTOCOL, Product, +}; +use secretspec_ipc::provider::{ + ProvidedSecret, ProviderHandler, SecretValue, request_credential, serve_provider, +}; +use secretspec_ipc::server::{RequestContext, RpcResult, ServerConfig}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Default)] +struct MemoryProvider { + values: Mutex>, + initialized_credential: Arc>>, +} + +fn key(address: Address) -> String { + match address { + Address::Convention { + project, + profile, + key, + } => format!("{project}/{profile}/{key}"), + Address::Native { coordinates } => coordinates.item, + } +} + +#[async_trait] +impl ProviderHandler for MemoryProvider { + fn capabilities(&self) -> Vec { + wire::CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() + } + + async fn initialize( + &self, + context: &RequestContext, + application: InitializeApplication, + ) -> RpcResult { + let credential = request_credential( + context, + CredentialParams { + name: "access_token".into(), + scope: application.uri.clone(), + required: false, + }, + ) + .await?; + *self.initialized_credential.lock().unwrap() = + credential.map(|value| value.expose().to_string()); + Ok(Metadata { + name: application.scheme.clone(), + display_uri: format!("{}://memory", application.scheme), + supported_coordinates: Vec::new(), + generated_value_persistence: Persistence::Persist, + prompted_value_persistence: Persistence::Ephemeral, + storage_identity: format!("{}://memory", application.scheme), + entry_container_identity: format!("{}://memory", application.scheme), + physical_store_path: None, + }) + } + + async fn resolve_address( + &self, + _context: RequestContext, + address: Address, + ) -> RpcResult { + Ok(ResolveAddressResult { + coordinates: wire::Coordinates { + item: key(address), + field: None, + vault: None, + section: None, + version: None, + }, + }) + } + + async fn get( + &self, + _context: RequestContext, + address: Address, + ) -> RpcResult> { + Ok(self + .values + .lock() + .unwrap() + .get(&key(address)) + .cloned() + .map(|value| ProvidedSecret::new(value, None))) + } + + async fn exists(&self, _context: RequestContext, address: Address) -> RpcResult { + Ok(self.values.lock().unwrap().contains_key(&key(address))) + } + + async fn set( + &self, + _context: RequestContext, + address: Address, + value: SecretValue, + ) -> RpcResult<()> { + self.values + .lock() + .unwrap() + .insert(key(address), value.expose().to_string()); + Ok(()) + } + + async fn delete(&self, _context: RequestContext, address: Address) -> RpcResult { + Ok(self.values.lock().unwrap().remove(&key(address)).is_some()) + } + + async fn check_writable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Ok(()) + } + + async fn check_deletable(&self, _context: RequestContext, _address: Address) -> RpcResult<()> { + Ok(()) + } + + async fn describe_write_target( + &self, + _context: RequestContext, + address: Address, + ) -> RpcResult { + Ok(format!("memory {}", key(address))) + } + + async fn reflect( + &self, + _context: RequestContext, + _params: ReflectParams, + ) -> RpcResult { + Ok(ReflectResult { + schema_version: 1, + declarations: BTreeMap::from([( + "TOKEN".into(), + wire::ReflectedDeclaration { + description: "Memory token".into(), + required: true, + reference: wire::Coordinates { + item: "token".into(), + field: None, + vault: None, + section: None, + version: None, + }, + }, + )]), + }) + } +} + +struct CredentialAnswer; + +#[async_trait] +impl CallbackHandler for CredentialAnswer { + async fn call( + &self, + method: &str, + params: serde_json::Value, + ) -> std::result::Result { + if method != callback::method::CREDENTIAL { + return Err(RpcError::new(ErrorKind::MethodNotFound)); + } + let params: CredentialParams = + serde_json::from_value(params).map_err(|_| RpcError::new(ErrorKind::InvalidParams))?; + assert_eq!(params.name, "access_token"); + assert_eq!(params.scope, "memory://default"); + serde_json::to_value(CredentialResult::Found { + value: "brokered-token".into(), + }) + .map_err(|_| RpcError::new(ErrorKind::Internal)) + } +} + +fn deadline() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + + Duration::from_secs(2).as_millis() as u64 +} + +fn address() -> Address { + Address::Convention { + project: "payments".into(), + profile: "production".into(), + key: "TOKEN".into(), + } +} + +#[tokio::test] +async fn typed_provider_handler_covers_naming_reads_mutations_and_reflection() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve_provider( + server_read, + server_write, + MemoryProvider::default(), + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: PROVIDER_PROTOCOL.into(), + versions: vec![PROTOCOL_VERSION], + client: Product { + name: "provider-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 8, + }, + client_methods: Vec::new(), + application: InitializeApplication { + scheme: "memory".into(), + uri: "memory://default".into(), + context: ApplicationContext { + project: Some("payments".into()), + profile: Some("production".into()), + base_dir: None, + reason: Some("test".into()), + requested_authorization_duration_ms: None, + }, + }, + }; + let (raw, initialized) = Client::connect::<_, _, _, InitializedApplication>( + client_read, + client_write, + initialize, + deadline(), + ) + .await + .unwrap(); + assert_eq!(initialized.application.provider.name, "memory"); + let client = raw; + + let resolved: ResolveAddressResult = client + .call( + wire::method::RESOLVE_ADDRESS, + &AddressParams { address: address() }, + deadline(), + ) + .await + .unwrap(); + assert_eq!(resolved.coordinates.item, "payments/production/TOKEN"); + + let missing: GetResult = client + .call( + wire::method::GET, + &AddressParams { address: address() }, + deadline(), + ) + .await + .unwrap(); + assert_eq!(missing, GetResult::Missing); + + let stored: wire::StoredResult = client + .call( + wire::method::SET, + &SetParams { + address: address(), + value: "canary-value".into(), + }, + deadline(), + ) + .await + .unwrap(); + assert!(stored.stored); + let found: GetResult = client + .call( + wire::method::GET, + &AddressParams { address: address() }, + deadline(), + ) + .await + .unwrap(); + assert_eq!( + found, + GetResult::Found { + value: "canary-value".into(), + expires_at_unix_ms: None, + } + ); + let reflected: ReflectResult = client + .call( + wire::method::REFLECT, + &ReflectParams { + project: "payments".into(), + profile: "production".into(), + }, + deadline(), + ) + .await + .unwrap(); + assert_eq!(reflected.declarations.len(), 1); + + let deleted: wire::DeletedResult = client + .call( + wire::method::DELETE, + &AddressParams { address: address() }, + deadline(), + ) + .await + .unwrap(); + assert!(deleted.deleted); + client.close(deadline()).await.unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn provider_can_request_a_credential_during_initialize() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let credential = Arc::new(Mutex::new(None)); + let provider = MemoryProvider { + values: Mutex::new(HashMap::new()), + initialized_credential: credential.clone(), + }; + let server = tokio::spawn(serve_provider( + server_read, + server_write, + provider, + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: PROVIDER_PROTOCOL.into(), + versions: vec![PROTOCOL_VERSION], + client: Product { + name: "provider-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 8, + }, + client_methods: vec![callback::method::CREDENTIAL.into()], + application: InitializeApplication { + scheme: "memory".into(), + uri: "memory://default".into(), + context: ApplicationContext { + project: Some("payments".into()), + profile: Some("production".into()), + base_dir: None, + reason: Some("test".into()), + requested_authorization_duration_ms: None, + }, + }, + }; + let (client, _initialized) = Client::connect_with_callbacks::<_, _, _, InitializedApplication>( + client_read, + client_write, + initialize, + deadline(), + Some(Arc::new(CredentialAnswer)), + ) + .await + .unwrap(); + + assert_eq!( + credential.lock().unwrap().as_deref(), + Some("brokered-token") + ); + client.close(deadline()).await.unwrap(); + server.await.unwrap().unwrap(); +} diff --git a/secretspec-ipc/tests/session.rs b/secretspec-ipc/tests/session.rs new file mode 100644 index 000000000..43349ac00 --- /dev/null +++ b/secretspec-ipc/tests/session.rs @@ -0,0 +1,1881 @@ +use async_trait::async_trait; +use secretspec_ipc::client::Client; +use secretspec_ipc::frame::{read_frame, write_frame}; +use secretspec_ipc::protocol::{InitializeParams, Limits, Product}; +use secretspec_ipc::server::{ApplicationHandler, RequestContext, RpcResult, ServerConfig, serve}; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +struct Echo; + +#[async_trait] +impl ApplicationHandler for Echo { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, _context: &RequestContext, application: Value) -> RpcResult { + Ok(application) + } + + async fn call( + &self, + context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + if params.get("wait").and_then(Value::as_bool) == Some(true) { + context.cancellation.cancelled().await; + } + Ok(params) + } +} + +struct SlowInitialize { + started: Arc, +} + +#[async_trait] +impl ApplicationHandler for SlowInitialize { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, context: &RequestContext, _application: Value) -> RpcResult { + self.started.add_permits(1); + context.cancellation.cancelled().await; + Ok(json!({})) + } + + async fn call( + &self, + _context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + Ok(params) + } +} + +fn deadline(after: Duration) -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + now + after.as_millis() as u64 +} + +async fn session() -> (Client, tokio::task::JoinHandle>) { + session_with_limit(4).await +} + +async fn session_with_limit( + max_in_flight: usize, +) -> (Client, tokio::task::JoinHandle>) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(Echo), + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight, + }, + client_methods: Vec::new(), + application: json!({}), + }; + let (client, _initialized) = Client::connect::<_, _, _, Value>( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + (client, server) +} + +fn initialize_request(id: u64) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "rpc.initialize", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": { + "protocol": "secretspec.resolver", + "versions": [1], + "client": {"name": "test", "version": "1"}, + "limits": {"max_frame_bytes": 32 * 1024, "max_in_flight": 4}, + "application": {} + } + }) +} + +async fn raw_server() -> ( + tokio::io::DuplexStream, + tokio::task::JoinHandle>, +) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(Echo), + ServerConfig::default(), + )); + (client_io, server) +} + +async fn call_when_slot_is_released(client: &Client, label: &str) -> Value { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match client + .call::<_, Value>( + "resolver.get", + &json!({"after": label}), + deadline(Duration::from_secs(2)), + ) + .await + { + Err(secretspec_ipc::Error::Unavailable) => tokio::task::yield_now().await, + outcome => return outcome.unwrap(), + } + } + }) + .await + .expect("the abandoned request never released its in-flight slot") +} + +#[tokio::test] +async fn initializes_calls_and_shuts_down() { + let (client, server) = session().await; + let call_deadline = deadline(Duration::from_secs(2)); + let result: Value = client + .call("resolver.get", &json!({"value": 42}), call_deadline) + .await + .unwrap(); + assert_eq!(result["value"], 42); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); +} + +struct DiscoveryWitness { + initialized: AtomicUsize, + shutdown: AtomicUsize, +} + +#[async_trait] +impl ApplicationHandler for DiscoveryWitness { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into(), "resolver.release".into()] + } + + async fn initialize(&self, _context: &RequestContext, application: Value) -> RpcResult { + self.initialized.fetch_add(1, Ordering::SeqCst); + Ok(application) + } + + async fn call( + &self, + _context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + Ok(params) + } + + async fn shutdown(&self) { + self.shutdown.fetch_add(1, Ordering::SeqCst); + } +} + +#[tokio::test] +async fn discovery_is_side_effect_free_before_and_available_after_initialization() { + let (mut client_io, server_io) = tokio::io::duplex(256 * 1024); + let (server_read, server_write) = tokio::io::split(server_io); + let witness = Arc::new(DiscoveryWitness { + initialized: AtomicUsize::new(0), + shutdown: AtomicUsize::new(0), + }); + let server = tokio::spawn(serve( + server_read, + server_write, + witness.clone(), + ServerConfig { + product: Product { + name: "discovery-test".into(), + version: "20".into(), + }, + ..ServerConfig::default() + }, + )); + + let expired = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "rpc.discover", + "_meta": {"deadline_unix_ms": 1}, + "params": {}, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&expired).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let expired_reply = read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .unwrap(); + let expired_reply: Value = serde_json::from_slice(&expired_reply).unwrap(); + assert_eq!(expired_reply["error"]["data"]["kind"], "deadline_exceeded"); + + let discover = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "rpc.discover", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": {}, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&discover).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let discovered = read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .unwrap(); + let discovered: Value = serde_json::from_slice(&discovered).unwrap(); + assert_eq!(discovered["result"]["openrpc"], "1.3.2"); + assert_eq!( + discovered["result"]["x-secretspec"]["protocol"], + "secretspec.resolver" + ); + assert_eq!( + discovered["result"]["x-secretspec"]["server"]["name"], + "discovery-test" + ); + assert_eq!( + discovered["result"]["x-secretspec"]["methods"], + json!(["resolver.get", "resolver.release"]) + ); + assert!( + discovered["result"]["methods"] + .as_array() + .unwrap() + .iter() + .any(|method| method["name"] == "rpc.discover") + ); + assert!(discovered["result"]["components"]["schemas"].is_object()); + assert_eq!(witness.initialized.load(Ordering::SeqCst), 0); + assert_eq!(witness.shutdown.load(Ordering::SeqCst), 0); + + let initialize = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "rpc.initialize", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": { + "protocol": "secretspec.resolver", + "versions": [1], + "client": {"name": "test", "version": "1"}, + "limits": {"max_frame_bytes": 256 * 1024, "max_in_flight": 4}, + "application": {}, + }, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&initialize).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let initialized = read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .unwrap(); + let initialized: Value = serde_json::from_slice(&initialized).unwrap(); + assert!(initialized.get("result").is_some()); + assert_eq!(witness.initialized.load(Ordering::SeqCst), 1); + + let discover_again = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "rpc.discover", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": {}, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&discover_again).unwrap(), + 256 * 1024, + ) + .await + .unwrap(); + let discovered_again = read_frame(&mut client_io, 256 * 1024) + .await + .unwrap() + .unwrap(); + let discovered_again: Value = serde_json::from_slice(&discovered_again).unwrap(); + assert_eq!( + discovered_again["result"]["x-secretspec"]["protocol"], + "secretspec.resolver" + ); + assert_eq!(witness.initialized.load(Ordering::SeqCst), 1); + + let shutdown = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "rpc.shutdown", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": {}, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&shutdown).unwrap(), + 256 * 1024, + ) + .await + .unwrap(); + let shutdown_reply = read_frame(&mut client_io, 256 * 1024) + .await + .unwrap() + .unwrap(); + let shutdown_reply: Value = serde_json::from_slice(&shutdown_reply).unwrap(); + assert!(shutdown_reply.get("result").is_some()); + server.await.unwrap().unwrap(); + assert_eq!(witness.shutdown.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn cancellation_has_one_terminal_result() { + let (client, server) = session().await; + let call_deadline = deadline(Duration::from_secs(2)); + let mut call = client + .start("resolver.get", &json!({"wait": true}), call_deadline) + .await + .unwrap(); + call.cancel().await.unwrap(); + assert!(matches!( + call.wait().await, + Err(secretspec_ipc::Error::Cancelled) + )); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn deadline_has_one_terminal_result() { + let (client, server) = session().await; + let call_deadline = deadline(Duration::from_millis(50)); + let error = client + .call::<_, Value>("resolver.get", &json!({"wait": true}), call_deadline) + .await + .unwrap_err(); + assert!(matches!(error, secretspec_ipc::Error::DeadlineExceeded)); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn an_in_flight_slot_is_reused_after_every_abandonment_path() { + let (client, server) = session_with_limit(1).await; + + let mut cancelled = client + .start( + "resolver.get", + &json!({"wait": true}), + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + assert!(matches!( + client + .call::<_, Value>( + "resolver.get", + &json!({"while": "cancel"}), + deadline(Duration::from_secs(2)), + ) + .await, + Err(secretspec_ipc::Error::Unavailable) + )); + cancelled.cancel().await.unwrap(); + assert!(matches!( + cancelled.wait().await, + Err(secretspec_ipc::Error::Cancelled) + )); + assert_eq!( + call_when_slot_is_released(&client, "cancel").await, + json!({"after": "cancel"}) + ); + + let mut expired = client + .start( + "resolver.get", + &json!({"wait": true}), + deadline(Duration::from_millis(50)), + ) + .await + .unwrap(); + assert!(matches!( + client + .call::<_, Value>( + "resolver.get", + &json!({"while": "deadline"}), + deadline(Duration::from_secs(2)), + ) + .await, + Err(secretspec_ipc::Error::Unavailable) + )); + assert!(matches!( + expired.wait().await, + Err(secretspec_ipc::Error::DeadlineExceeded) + )); + assert_eq!( + call_when_slot_is_released(&client, "deadline").await, + json!({"after": "deadline"}) + ); + + let dropped = client + .start( + "resolver.get", + &json!({"wait": true}), + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + assert!(matches!( + client + .call::<_, Value>( + "resolver.get", + &json!({"while": "drop"}), + deadline(Duration::from_secs(2)), + ) + .await, + Err(secretspec_ipc::Error::Unavailable) + )); + drop(dropped); + assert_eq!( + call_when_slot_is_released(&client, "drop").await, + json!({"after": "drop"}) + ); + + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn rejected_initialization_closes_both_transport_tasks() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let peer = tokio::spawn(async move { + let request = read_frame(&mut peer_io, 1_048_576) + .await + .unwrap() + .expect("initialization request"); + assert_eq!(serde_json::from_slice::(&request).unwrap()["id"], 1); + let response = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocol": "wrong.protocol", + "version": 1, + "server": {"name": "fake", "version": "1"}, + "methods": ["resolver.get"], + "capabilities": {}, + "limits": {"max_frame_bytes": 32768, "max_in_flight": 4}, + "application": {} + } + })) + .unwrap(); + write_frame(&mut peer_io, &response, 1_048_576) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), read_frame(&mut peer_io, 1_048_576)) + .await + .expect("failed initialization leaked a transport task") + .unwrap() + .is_none() + }); + + let (client_read, client_write) = tokio::io::split(client_io); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + }; + assert!( + Client::connect::<_, _, _, Value>( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + ) + .await + .is_err() + ); + assert!(peer.await.unwrap()); +} + +#[tokio::test] +async fn deadline_does_not_wait_for_cancel_queue_capacity() { + let (client_io, mut peer_io) = tokio::io::duplex(64); + let peer = tokio::spawn(async move { + let request = read_frame(&mut peer_io, 1_048_576) + .await + .unwrap() + .expect("initialization request"); + assert_eq!(serde_json::from_slice::(&request).unwrap()["id"], 1); + let response = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocol": "secretspec.resolver", + "version": 1, + "server": {"name": "backpressured", "version": "1"}, + "methods": ["resolver.get"], + "capabilities": {}, + "limits": {"max_frame_bytes": 4096, "max_in_flight": 4}, + "application": {} + } + })) + .unwrap(); + write_frame(&mut peer_io, &response, 1_048_576) + .await + .unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + }); + + let (client_read, client_write) = tokio::io::split(client_io); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 4096, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + }; + let (client, _) = Client::connect::<_, _, _, Value>( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + + let call_deadline = deadline(Duration::from_millis(75)); + let mut waiters = Vec::new(); + for _ in 0..4 { + let mut call = client + .start( + "resolver.get", + &json!({ + "padding": "x".repeat(3000) + }), + call_deadline, + ) + .await + .unwrap(); + waiters.push(tokio::spawn(async move { call.wait().await })); + } + + tokio::time::timeout(Duration::from_millis(500), async { + for waiter in waiters { + assert!(matches!( + waiter.await.unwrap(), + Err(secretspec_ipc::Error::DeadlineExceeded) + )); + } + }) + .await + .expect("deadline handling blocked behind the writer queue"); + + let _ = client.close(deadline(Duration::from_millis(100))).await; + peer.abort(); + let _ = peer.await; +} + +#[tokio::test] +async fn dropped_calls_are_bounded_as_abandoned_requests() { + let (client_io, mut peer_io) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let request = read_frame(&mut peer_io, 1_048_576) + .await + .unwrap() + .expect("initialization request"); + assert_eq!(serde_json::from_slice::(&request).unwrap()["id"], 1); + let response = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocol": "secretspec.resolver", + "version": 1, + "server": {"name": "nonresponsive", "version": "1"}, + "methods": ["resolver.get"], + "capabilities": {}, + "limits": {"max_frame_bytes": 4096, "max_in_flight": 4}, + "application": {} + } + })) + .unwrap(); + write_frame(&mut peer_io, &response, 1_048_576) + .await + .unwrap(); + while read_frame(&mut peer_io, 4096).await.unwrap().is_some() {} + }); + + let (client_read, client_write) = tokio::io::split(client_io); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 4096, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: json!({}), + }; + let (client, _) = Client::connect::<_, _, _, Value>( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + + for _ in 0..200 { + let call_deadline = deadline(Duration::from_secs(5)); + match client + .start("resolver.get", &json!({}), call_deadline) + .await + { + Ok(call) => drop(call), + Err(secretspec_ipc::Error::Closed) => break, + Err(error) => panic!("unexpected call error: {error:?}"), + } + } + assert!(client.is_closed()); + + client + .close(deadline(Duration::from_millis(100))) + .await + .unwrap(); + peer.await.unwrap(); +} + +/// Records whether the session ran its shutdown hook. +struct ShutdownWitness { + shutdown: Arc, +} + +#[async_trait] +impl ApplicationHandler for ShutdownWitness { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, _context: &RequestContext, application: Value) -> RpcResult { + Ok(application) + } + + async fn call( + &self, + _context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + Ok(params) + } + + async fn shutdown(&self) { + self.shutdown.notify_waiters(); + } +} + +#[tokio::test] +async fn transport_failure_still_runs_session_cleanup() { + // A frame that violates the wire rules used to propagate straight out of + // `serve`, skipping in-flight cancellation, task joining, and the handler's + // shutdown hook. Resources such as resolver leases depend on that hook, so a + // hostile or broken peer must not be able to skip it. + let (mut client_io, server_io) = tokio::io::duplex(64 * 1024); + let (server_read, server_write) = tokio::io::split(server_io); + let shutdown = Arc::new(tokio::sync::Notify::new()); + let observed = shutdown.clone(); + let witness = Arc::new(ShutdownWitness { + shutdown: shutdown.clone(), + }); + let server = tokio::spawn(serve( + server_read, + server_write, + witness, + ServerConfig::default(), + )); + + // Initialize by hand so the session owns application state; `shutdown` is + // deliberately not called for a session that never initialized. + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "rpc.initialize", + "_meta": {"deadline_unix_ms": deadline(Duration::from_secs(2))}, + "params": { + "protocol": "secretspec.resolver", + "versions": [1], + "client": {"name": "test", "version": "1"}, + "limits": {"max_frame_bytes": 32 * 1024, "max_in_flight": 4}, + "application": {}, + }, + }); + write_frame( + &mut client_io, + &serde_json::to_vec(&initialize).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let reply = read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .unwrap(); + let reply: Value = serde_json::from_slice(&reply).unwrap(); + assert!(reply.get("result").is_some(), "initialization must succeed"); + + let ready = tokio::spawn(async move { observed.notified().await }); + // Let the watcher arm before the session fails. + tokio::task::yield_now().await; + + // An unterminated line beyond the active frame limit must fail the session + // without allocating an unbounded buffer. + { + use tokio::io::AsyncWriteExt; + client_io + .write_all(&vec![b'x'; 32 * 1024 + 1]) + .await + .unwrap(); + client_io.flush().await.unwrap(); + } + + // The session reports the protocol violation to its caller ... + let outcome = server.await.unwrap(); + assert!(outcome.is_err(), "an oversized frame must fail the session"); + // ... and still ran cleanup on the way out. + tokio::time::timeout(Duration::from_secs(2), ready) + .await + .expect("session cleanup must run even when the transport fails") + .unwrap(); +} + +#[tokio::test] +async fn initialization_state_violations_return_one_error_and_close() { + // An application request before initialization is rejected and terminal. + let (mut io, server) = raw_server().await; + let request = json!({ + "jsonrpc":"2.0", "id":1, "method":"resolver.get", + "_meta":{"deadline_unix_ms":deadline(Duration::from_secs(2))}, "params":{} + }); + write_frame(&mut io, &serde_json::to_vec(&request).unwrap(), 1_048_576) + .await + .unwrap(); + let response: Value = + serde_json::from_slice(&read_frame(&mut io, 1_048_576).await.unwrap().unwrap()).unwrap(); + assert_eq!(response["error"]["data"]["kind"], "invalid_request"); + assert!(read_frame(&mut io, 1_048_576).await.unwrap().is_none()); + server.await.unwrap().unwrap(); + + // A response that cannot belong to an initialization callback closes + // immediately because responses have no response channel. + let (mut io, server) = raw_server().await; + let response = json!({"jsonrpc":"2.0", "id":1, "result":{}}); + write_frame(&mut io, &serde_json::to_vec(&response).unwrap(), 1_048_576) + .await + .unwrap(); + assert!(read_frame(&mut io, 1_048_576).await.unwrap().is_none()); + server.await.unwrap().unwrap(); + + // A second initialize after readiness receives one error and closes. + let (mut io, server) = raw_server().await; + write_frame( + &mut io, + &serde_json::to_vec(&initialize_request(1)).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let initialized: Value = + serde_json::from_slice(&read_frame(&mut io, 1_048_576).await.unwrap().unwrap()).unwrap(); + assert!(initialized.get("result").is_some()); + write_frame( + &mut io, + &serde_json::to_vec(&initialize_request(2)).unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + let response: Value = + serde_json::from_slice(&read_frame(&mut io, 32 * 1024).await.unwrap().unwrap()).unwrap(); + assert_eq!(response["error"]["data"]["kind"], "invalid_request"); + assert!(read_frame(&mut io, 32 * 1024).await.unwrap().is_none()); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn second_initialize_while_the_first_is_active_cancels_startup_and_closes() { + let (mut client_io, server_io) = tokio::io::duplex(64 * 1024); + let (server_read, server_write) = tokio::io::split(server_io); + let started = Arc::new(tokio::sync::Semaphore::new(0)); + let server_started = started.clone(); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(SlowInitialize { + started: server_started, + }), + ServerConfig::default(), + )); + write_frame( + &mut client_io, + &serde_json::to_vec(&initialize_request(1)).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + started.acquire().await.unwrap().forget(); + write_frame( + &mut client_io, + &serde_json::to_vec(&initialize_request(2)).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let response: Value = serde_json::from_slice( + &read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(response["id"], 2); + assert_eq!(response["error"]["data"]["kind"], "invalid_request"); + assert!( + read_frame(&mut client_io, 1_048_576) + .await + .unwrap() + .is_none() + ); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn invalid_initialization_is_distinct_from_unsupported_version() { + for (mut request, expected) in [ + ( + { + let mut request = initialize_request(1); + request["params"]["limits"]["max_in_flight"] = json!(0); + request + }, + "invalid_params", + ), + ( + { + let mut request = initialize_request(1); + request["params"]["protocol"] = json!("unknown.protocol"); + request + }, + "unsupported_version", + ), + ( + { + let mut request = initialize_request(1); + request["params"]["versions"] = json!([999]); + request + }, + "unsupported_version", + ), + ] { + let (mut io, server) = raw_server().await; + request["_meta"]["deadline_unix_ms"] = json!(deadline(Duration::from_secs(2))); + write_frame(&mut io, &serde_json::to_vec(&request).unwrap(), 1_048_576) + .await + .unwrap(); + let response: Value = + serde_json::from_slice(&read_frame(&mut io, 1_048_576).await.unwrap().unwrap()) + .unwrap(); + assert_eq!(response["error"]["data"]["kind"], expected); + assert!(read_frame(&mut io, 1_048_576).await.unwrap().is_none()); + server.await.unwrap().unwrap(); + } +} + +#[tokio::test] +async fn notifications_are_structurally_strict_but_unknown_methods_are_ignored() { + let (mut io, server) = raw_server().await; + write_frame( + &mut io, + &serde_json::to_vec(&initialize_request(1)).unwrap(), + 1_048_576, + ) + .await + .unwrap(); + let _ = read_frame(&mut io, 1_048_576).await.unwrap().unwrap(); + + for notification in [ + json!({"jsonrpc":"2.0", "method":"future.notice", "params":{}}), + json!({"jsonrpc":"2.0", "method":"rpc.cancel", "params":{"id":"bad"}}), + json!({"jsonrpc":"2.0", "method":"rpc.cancel", "params":{"id":999}}), + json!({"jsonrpc":"2.0", "method":"rpc.cancel", "params":{"id":1}}), + ] { + write_frame( + &mut io, + &serde_json::to_vec(¬ification).unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + } + let discover = json!({ + "jsonrpc":"2.0", "id":2, "method":"rpc.discover", + "_meta":{"deadline_unix_ms":deadline(Duration::from_secs(2))}, "params":{} + }); + write_frame(&mut io, &serde_json::to_vec(&discover).unwrap(), 32 * 1024) + .await + .unwrap(); + let response: Value = + serde_json::from_slice(&read_frame(&mut io, 32 * 1024).await.unwrap().unwrap()).unwrap(); + assert_eq!(response["id"], 2); + + let invalid = json!({ + "jsonrpc":"2.0", "method":"future.notice", "params":{}, "extra":true + }); + write_frame(&mut io, &serde_json::to_vec(&invalid).unwrap(), 32 * 1024) + .await + .unwrap(); + let response: Value = + serde_json::from_slice(&read_frame(&mut io, 32 * 1024).await.unwrap().unwrap()).unwrap(); + assert_eq!(response["error"]["data"]["kind"], "invalid_request"); + assert!(read_frame(&mut io, 32 * 1024).await.unwrap().is_none()); + server.await.unwrap().unwrap(); +} + +/// The one direction reversal in version 1, at the wire layer. +/// +/// A handler asks its client something mid-request; the answer comes back on +/// the same connection while the client is still waiting for its own response. +/// The client that advertised nothing must be told immediately instead, which +/// is what keeps a headless consumer from waiting out a deadline. +mod callbacks { + use super::*; + use secretspec_ipc::client::CallbackHandler; + use secretspec_ipc::error::{ErrorKind, RpcError}; + use secretspec_ipc::server::Peer; + use std::future::pending; + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::Semaphore; + + const CANARY: &str = "SECRETSPEC_LATE_CALLBACK_SECRET"; + + fn initialize_response(id: u64, max_in_flight: usize) -> Vec { + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocol": "secretspec.resolver", + "version": 1, + "server": {"name": "callback-peer", "version": "1"}, + "methods": ["resolver.get"], + "capabilities": {}, + "limits": { + "max_frame_bytes": 32 * 1024, + "max_in_flight": max_in_flight + }, + "application": {} + } + })) + .unwrap() + } + + fn callback(id: u64, deadline_unix_ms: u64) -> Vec { + callback_with_parent(id, deadline_unix_ms, Some(2)) + } + + fn callback_with_parent( + id: u64, + deadline_unix_ms: u64, + parent_request_id: Option, + ) -> Vec { + let mut request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "client.prompt", + "_meta": {"deadline_unix_ms": deadline_unix_ms}, + "params": {"name": "TOKEN"} + }); + if let Some(parent_request_id) = parent_request_id { + request["_meta"]["parent_request_id"] = json!(parent_request_id); + } + serde_json::to_vec(&request).unwrap() + } + + async fn raw_client( + client_io: tokio::io::DuplexStream, + max_in_flight: usize, + handler: Arc, + ) -> Client { + let (client_read, client_write) = tokio::io::split(client_io); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "callback-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight, + }, + client_methods: vec!["client.prompt".into()], + application: json!({}), + }; + Client::connect_with_callbacks::<_, _, _, Value>( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + Some(handler), + ) + .await + .unwrap() + .0 + } + + struct Asks; + + #[async_trait] + impl ApplicationHandler for Asks { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, _context: &RequestContext, _: Value) -> RpcResult { + Ok(json!({})) + } + + async fn call( + &self, + context: RequestContext, + _method: &str, + _params: Value, + ) -> RpcResult { + let supported = context.peer.supports("client.prompt"); + let answer: Value = context + .peer + .call("client.prompt", &json!({"name": "TOKEN"}), &context) + .await + .unwrap_or_else(|error| json!({"error": error.data.kind.as_str()})); + Ok(json!({"supported": supported, "answer": answer})) + } + } + + struct Answers; + + #[async_trait] + impl CallbackHandler for Answers { + async fn call(&self, method: &str, params: Value) -> Result { + if method != "client.prompt" { + return Err(RpcError::new(ErrorKind::MethodNotFound)); + } + Ok(json!({"echoed": params["name"]})) + } + } + + struct AsksUntilExpiry; + + #[async_trait] + impl ApplicationHandler for AsksUntilExpiry { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, _context: &RequestContext, _: Value) -> RpcResult { + Ok(json!({})) + } + + async fn call( + &self, + context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + if params.get("prompt").and_then(Value::as_bool) == Some(true) { + let outcome: Result = context + .peer + .call("client.prompt", &json!({"name": "TOKEN"}), &context) + .await; + return Ok(json!({ + "callback": outcome + .map(|_| "answered") + .unwrap_or_else(|error| error.data.kind.as_str()) + })); + } + Ok(json!({"alive": true})) + } + } + + struct DetachesInitializeCallback { + callback_started: Arc, + } + + #[async_trait] + impl ApplicationHandler for DetachesInitializeCallback { + fn protocol(&self) -> &'static str { + "secretspec.resolver" + } + + fn capabilities(&self) -> Vec { + vec!["resolver.get".into()] + } + + async fn initialize(&self, context: &RequestContext, _: Value) -> RpcResult { + let peer = context.peer.clone(); + let context = context.clone(); + tokio::spawn(async move { + let _: RpcResult = peer + .call("client.prompt", &json!({"name": "TOKEN"}), &context) + .await; + }); + // Return while the callback is still running so its cancellation + // races the initialize response boundary deterministically. + self.callback_started.acquire().await.unwrap().forget(); + Ok(json!({})) + } + + async fn call( + &self, + _context: RequestContext, + _method: &str, + params: Value, + ) -> RpcResult { + Ok(params) + } + } + + struct NeverAnswers; + + #[async_trait] + impl CallbackHandler for NeverAnswers { + async fn call(&self, _method: &str, _params: Value) -> Result { + pending().await + } + } + + struct RecordsCancellation { + started: Arc, + dropped: Arc, + } + + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + #[async_trait] + impl CallbackHandler for RecordsCancellation { + async fn call(&self, _method: &str, _params: Value) -> Result { + let _drop = DropFlag(self.dropped.clone()); + self.started.add_permits(1); + pending().await + } + } + + async fn ask(advertise: bool) -> Value { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(Asks), + ServerConfig::default(), + )); + let (capabilities, handler): (Vec, Option>) = if advertise + { + (vec!["client.prompt".into()], Some(Arc::new(Answers))) + } else { + (Vec::new(), None) + }; + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "callback-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: capabilities, + application: json!({}), + }; + let (client, _): (Client, secretspec_ipc::protocol::InitializeResult) = + Client::connect_with_callbacks( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + handler, + ) + .await + .unwrap(); + let result: Value = client + .call("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await + .unwrap(); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + let _ = server.await; + result + } + + #[tokio::test] + async fn an_advertised_callback_is_answered_mid_request() { + let result = ask(true).await; + assert_eq!(result["supported"], json!(true)); + assert_eq!(result["answer"], json!({"echoed": "TOKEN"})); + } + + #[tokio::test] + async fn initialize_response_cancels_a_still_running_callback() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let started = Arc::new(Semaphore::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(DetachesInitializeCallback { + callback_started: started.clone(), + }), + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "callback-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 2, + }, + client_methods: vec!["client.prompt".into()], + application: json!({}), + }; + let (client, _): (Client, secretspec_ipc::protocol::InitializeResult) = + Client::connect_with_callbacks( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + Some(Arc::new(RecordsCancellation { + started, + dropped: dropped.clone(), + })), + ) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("initialize-time callback survived readiness"); + let result: Value = client + .call( + "resolver.get", + &json!({"alive": true}), + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + assert_eq!(result, json!({"alive": true})); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn an_unadvertised_callback_is_refused_without_reaching_the_client() { + let result = ask(false).await; + assert_eq!(result["supported"], json!(false)); + assert_eq!(result["answer"], json!({"error": "capability_required"})); + } + + #[tokio::test] + async fn a_completed_callback_id_cannot_be_reused() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame(&mut peer_io, &callback(7, parent_deadline), 32 * 1024) + .await + .unwrap(); + let answer = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + assert_eq!(serde_json::from_slice::(&answer).unwrap()["id"], 7); + write_frame(&mut peer_io, &callback(7, parent_deadline), 32 * 1024) + .await + .unwrap(); + while read_frame(&mut peer_io, 32 * 1024).await.unwrap().is_some() {} + }); + + let client = raw_client(client_io, 1, Arc::new(Answers)).await; + let error = client + .call::<_, Value>("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await + .unwrap_err(); + assert!(matches!(error, secretspec_ipc::Error::Closed)); + assert!(client.is_closed()); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + peer.await.unwrap(); + } + + #[tokio::test] + async fn callbacks_obey_the_negotiated_in_flight_limit() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame(&mut peer_io, &callback(8, parent_deadline), 32 * 1024) + .await + .unwrap(); + write_frame(&mut peer_io, &callback(9, parent_deadline), 32 * 1024) + .await + .unwrap(); + while read_frame(&mut peer_io, 32 * 1024).await.unwrap().is_some() {} + }); + + let client = raw_client(client_io, 1, Arc::new(NeverAnswers)).await; + let error = client + .call::<_, Value>("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await + .unwrap_err(); + assert!(matches!(error, secretspec_ipc::Error::Closed)); + assert!(client.is_closed()); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + peer.await.unwrap(); + } + + #[tokio::test] + async fn callback_deadline_cannot_exceed_its_parent() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame( + &mut peer_io, + &callback(8, parent_deadline.saturating_add(1)), + 32 * 1024, + ) + .await + .unwrap(); + read_frame(&mut peer_io, 32 * 1024).await.unwrap().is_none() + }); + + let client = raw_client(client_io, 1, Arc::new(Answers)).await; + assert!(matches!( + client + .call::<_, Value>("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await, + Err(secretspec_ipc::Error::Closed) + )); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + assert!(peer.await.unwrap()); + } + + #[tokio::test] + async fn callback_parent_must_be_present_and_active() { + for parent_request_id in [None, Some(999)] { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame( + &mut peer_io, + &callback_with_parent(8, deadline, parent_request_id), + 32 * 1024, + ) + .await + .unwrap(); + read_frame(&mut peer_io, 32 * 1024).await.unwrap().is_none() + }); + + let client = raw_client(client_io, 1, Arc::new(Answers)).await; + assert!(matches!( + client + .call::<_, Value>("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await, + Err(secretspec_ipc::Error::Closed) + )); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + assert!(peer.await.unwrap()); + } + } + + #[tokio::test] + async fn cancelling_a_parent_cancels_its_running_callback() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let started = Arc::new(Semaphore::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let observed_started = started.clone(); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_id = call["id"].as_u64().unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame(&mut peer_io, &callback(8, parent_deadline), 32 * 1024) + .await + .unwrap(); + + let mut saw_cancel = false; + let mut saw_callback_terminal = false; + for _ in 0..2 { + let frame = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let frame: Value = serde_json::from_slice(&frame).unwrap(); + if frame.get("method").and_then(Value::as_str) == Some("rpc.cancel") { + saw_cancel = frame["params"]["id"] == parent_id; + } else if frame.get("id").and_then(Value::as_u64) == Some(8) { + saw_callback_terminal = frame["error"]["data"]["kind"] == "cancelled"; + } + } + assert!(saw_cancel); + assert!(saw_callback_terminal); + write_frame( + &mut peer_io, + &serde_json::to_vec( + &json!({"jsonrpc":"2.0","id":parent_id,"result":{"done":true}}), + ) + .unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + let shutdown = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let shutdown: Value = serde_json::from_slice(&shutdown).unwrap(); + write_frame( + &mut peer_io, + &serde_json::to_vec(&json!({"jsonrpc":"2.0","id":shutdown["id"],"result":{}})) + .unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + }); + + let client = raw_client( + client_io, + 1, + Arc::new(RecordsCancellation { + started, + dropped: dropped.clone(), + }), + ) + .await; + let mut call = client + .start("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await + .unwrap(); + observed_started.acquire().await.unwrap().forget(); + call.cancel().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("callback handler survived parent cancellation"); + assert_eq!(call.wait().await.unwrap(), json!({"done": true})); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + peer.await.unwrap(); + } + + #[tokio::test] + async fn disconnect_cancels_a_running_callback() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let started = Arc::new(Semaphore::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let peer_started = started.clone(); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame(&mut peer_io, &callback(8, parent_deadline), 32 * 1024) + .await + .unwrap(); + peer_started.acquire().await.unwrap().forget(); + }); + + let client = raw_client( + client_io, + 1, + Arc::new(RecordsCancellation { + started, + dropped: dropped.clone(), + }), + ) + .await; + assert!(matches!( + client + .call::<_, Value>("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await, + Err(secretspec_ipc::Error::Closed) + )); + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("callback handler survived disconnect"); + peer.await.unwrap(); + } + + #[tokio::test] + async fn terminal_parent_cancels_callback_and_discards_its_result() { + let (client_io, mut peer_io) = tokio::io::duplex(64 * 1024); + let started = Arc::new(Semaphore::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let peer_started = started.clone(); + let peer = tokio::spawn(async move { + let initialize = read_frame(&mut peer_io, 1024 * 1024) + .await + .unwrap() + .unwrap(); + let initialize: Value = serde_json::from_slice(&initialize).unwrap(); + write_frame( + &mut peer_io, + &initialize_response(initialize["id"].as_u64().unwrap(), 1), + 1024 * 1024, + ) + .await + .unwrap(); + + let call = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let call: Value = serde_json::from_slice(&call).unwrap(); + let parent_deadline = call["_meta"]["deadline_unix_ms"].as_u64().unwrap(); + write_frame(&mut peer_io, &callback(8, parent_deadline), 32 * 1024) + .await + .unwrap(); + peer_started.acquire().await.unwrap().forget(); + write_frame( + &mut peer_io, + &serde_json::to_vec(&json!({"jsonrpc":"2.0","id":2,"result":{"done":true}})) + .unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + + let callback_response = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + assert!( + !callback_response + .windows(CANARY.len()) + .any(|bytes| bytes == CANARY.as_bytes()) + ); + let callback_response: Value = serde_json::from_slice(&callback_response).unwrap(); + assert_eq!(callback_response["id"], 8); + assert_eq!(callback_response["error"]["data"]["kind"], "cancelled"); + + let shutdown = read_frame(&mut peer_io, 32 * 1024).await.unwrap().unwrap(); + let shutdown: Value = serde_json::from_slice(&shutdown).unwrap(); + write_frame( + &mut peer_io, + &serde_json::to_vec(&json!({"jsonrpc":"2.0","id":shutdown["id"],"result":{}})) + .unwrap(), + 32 * 1024, + ) + .await + .unwrap(); + }); + + let handler = RecordsCancellation { + started, + dropped: dropped.clone(), + }; + let client = raw_client(client_io, 1, Arc::new(handler)).await; + let result: Value = client + .call("resolver.get", &json!({}), deadline(Duration::from_secs(2))) + .await + .unwrap(); + assert_eq!(result, json!({"done": true})); + tokio::task::yield_now().await; + assert!(dropped.load(Ordering::Acquire)); + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + peer.await.unwrap(); + } + + #[tokio::test] + async fn a_detached_peer_advertises_nothing() { + // What a handler exercised outside a live transport sees. It must match + // what a real session reports for a client that advertised nothing, so + // a test cannot accidentally prove behavior a headless consumer would + // not get. + assert!(!Peer::detached().supports("client.prompt")); + } + + #[tokio::test] + async fn an_expired_callback_terminal_does_not_close_the_session() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve( + server_read, + server_write, + Arc::new(AsksUntilExpiry), + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: Product { + name: "expired-callback-test".into(), + version: "1".into(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: vec!["client.prompt".into()], + application: json!({}), + }; + let (client, _): (Client, secretspec_ipc::protocol::InitializeResult) = + Client::connect_with_callbacks( + client_read, + client_write, + initialize, + deadline(Duration::from_secs(2)), + Some(Arc::new(NeverAnswers)), + ) + .await + .unwrap(); + + // Repetition proves consumed late terminals do not accumulate in the + // bounded abandoned-ID set. Each health check also proves the response + // did not get misclassified as an unmatched terminal and kill the + // transport. + for _ in 0..16 { + let outcome = client + .call::<_, Value>( + "resolver.get", + &json!({"prompt": true}), + deadline(Duration::from_millis(50)), + ) + .await; + match outcome { + Ok(result) => assert_eq!(result, json!({"callback": "deadline_exceeded"})), + Err(error) => { + assert_eq!(error.rpc_kind(), Some(ErrorKind::DeadlineExceeded)); + } + } + + let result: Value = client + .call( + "resolver.get", + &json!({"prompt": false}), + deadline(Duration::from_secs(2)), + ) + .await + .unwrap(); + assert_eq!(result, json!({"alive": true})); + } + + client + .close(deadline(Duration::from_secs(2))) + .await + .unwrap(); + server.await.unwrap().unwrap(); + } +} diff --git a/secretspec-node/index.js b/secretspec-node/index.js index c90caa511..779796afb 100644 --- a/secretspec-node/index.js +++ b/secretspec-node/index.js @@ -77,7 +77,7 @@ function loadNative() { const native = loadNative(); -// Response wire-format version this SDK understands. Tracks secretspec-ffi's +// Response wire-format version this SDK understands. Tracks libsecretspec's // RESOLVE_SCHEMA_VERSION; a mismatch means the native addon is out of sync. const RESOLVE_SCHEMA_VERSION = 2; diff --git a/secretspec-php/.gitignore b/secretspec-php/.gitignore index d8d9cc84e..e11796d51 100644 --- a/secretspec-php/.gitignore +++ b/secretspec-php/.gitignore @@ -2,5 +2,5 @@ .phpunit.result.cache /.phpunit.cache/ # Native artifacts staged for distribution, never committed (see RELEASE.md): -# the bundled secretspec-ffi cdylib and the staged secretspec.so extension. +# the bundled libsecretspec cdylib and the staged secretspec.so extension. /lib/ diff --git a/secretspec-php/README.md b/secretspec-php/README.md index af7504ff7..fe60f28fd 100644 --- a/secretspec-php/README.md +++ b/secretspec-php/README.md @@ -12,9 +12,13 @@ contract, preferring the first that is available: [ext-php-rs](https://github.com/davidcole1340/ext-php-rs), crate `secretspec-php-native`) embeds the resolver like `ext-redis` does — no `ffi.enable`, works under PHP-FPM. Recommended for Laravel/Symfony. -2. **`ext-ffi`** dlopens the `secretspec-ffi` shared library at runtime. Nothing +2. **`ext-ffi`** dlopens the `libsecretspec` shared library at runtime. Nothing to compile; ideal for CLI and local development. +> The embedded ABI is named `libsecretspec` in SecretSpec 0.20+. It was named +> `secretspec-ffi` through 0.19; the 0.20+ FFI loader accepts both shared +> library filename families. + ## Install ```bash @@ -68,7 +72,7 @@ composer install # run at the repo root; installs to secretspec- # Backend 1: ext-ffi fallback. Build the cdylib; it is discovered via the # nearest Cargo target/ dir (or set SECRETSPEC_FFI_LIB). -cargo build -p secretspec-ffi +cargo build -p libsecretspec ( cd secretspec-php && ./vendor/bin/phpunit ) # Backend 2: the native extension. Build and load it. diff --git a/secretspec-php/bin/secretspec-install-lib b/secretspec-php/bin/secretspec-install-lib index ab874eb99..32d15ba19 100755 --- a/secretspec-php/bin/secretspec-install-lib +++ b/secretspec-php/bin/secretspec-install-lib @@ -1,7 +1,7 @@ #!/usr/bin/env php $bestMtime) { - $best = $candidate; - $bestMtime = $mtime; + foreach ($names as $name) { + $candidate = $dir . \DIRECTORY_SEPARATOR . 'target' + . \DIRECTORY_SEPARATOR . $profile . \DIRECTORY_SEPARATOR . $name; + if (\is_file($candidate)) { + $mtime = \filemtime($candidate); + if ($mtime !== false && $mtime > $bestMtime) { + $best = $candidate; + $bestMtime = $mtime; + } } } } @@ -163,21 +168,27 @@ private static function locateLibrary(): string throw new SecretSpecException( 'load', - 'could not locate the secretspec-ffi library; set SECRETSPEC_FFI_LIB to its path', + 'could not locate the libsecretspec library; set SECRETSPEC_FFI_LIB to its path', ); } /** - * The platform-specific `libsecretspec_ffi` file name the loader looks for. + * The platform-specific `libsecretspec` file name the loader looks for. * Shared with the `secretspec-install-lib` script so the downloaded copy and * the loader agree on one name. */ public static function libraryFileName(): string + { + return self::libraryFileNames()[0]; + } + + /** @return list New public name followed by the pre-0.20 name. */ + private static function libraryFileNames(): array { return match (\PHP_OS_FAMILY) { - 'Darwin' => 'libsecretspec_ffi.dylib', - 'Windows' => 'secretspec_ffi.dll', - default => 'libsecretspec_ffi.so', + 'Darwin' => ['libsecretspec.dylib', 'libsecretspec_ffi.dylib'], + 'Windows' => ['libsecretspec.dll', 'secretspec.dll', 'secretspec_ffi.dll'], + default => ['libsecretspec.so', 'libsecretspec_ffi.so'], }; } } diff --git a/secretspec-php/src/SecretSpec.php b/secretspec-php/src/SecretSpec.php index 5d8a7ac6d..a7361423d 100644 --- a/secretspec-php/src/SecretSpec.php +++ b/secretspec-php/src/SecretSpec.php @@ -8,7 +8,7 @@ * Entry point for the SecretSpec PHP SDK, mirroring the Rust derive crate's * `SecretSpec::builder()`. * - * The SDK is a thin client over the `secretspec-ffi` C ABI (loaded via PHP's + * The SDK is a thin client over the `libsecretspec` C ABI (loaded via PHP's * FFI extension): resolution — providers, fallback chains, profiles, generation, * `as_path` materialization — happens entirely in the Rust core, so every * provider works with no PHP-side logic. diff --git a/secretspec-py/secretspec/__init__.py b/secretspec-py/secretspec/__init__.py index 1d7e9ac29..f6ed9f682 100644 --- a/secretspec-py/secretspec/__init__.py +++ b/secretspec-py/secretspec/__init__.py @@ -22,7 +22,7 @@ from secretspec import _native -# Response wire-format version this SDK understands. Tracks secretspec-ffi's +# Response wire-format version this SDK understands. Tracks libsecretspec's # RESOLVE_SCHEMA_VERSION; a mismatch means the loaded library is incompatible. _RESOLVE_SCHEMA_VERSION = 2 @@ -219,7 +219,7 @@ def _checked_response(request: dict, kind: str, expected_version: int) -> dict: raise SecretSpecError( "version", f"unsupported {kind} schema version {version} (expected " - f"{expected_version}); the secretspec-ffi library and this SDK " + f"{expected_version}); the libsecretspec library and this SDK " "are out of sync", ) return response diff --git a/secretspec-rb/README.md b/secretspec-rb/README.md index 0e318affc..c73c0cb8f 100644 --- a/secretspec-rb/README.md +++ b/secretspec-rb/README.md @@ -1,10 +1,14 @@ # secretspec (Ruby SDK) Ruby bindings for [SecretSpec](https://secretspec.dev/), a declarative secrets -manager. A thin client over the `secretspec-ffi` C ABI, linked into a native C +manager. A thin client over the `libsecretspec` C ABI, linked into a native C extension at build time. Resolution happens in the Rust core, so the SDK inherits every provider with no Ruby-side logic. +> The embedded ABI is named `libsecretspec` in SecretSpec 0.20+. It was named +> `secretspec-ffi` through 0.19; the 0.20+ extension build accepts both static +> archive filename families. + ```ruby require "secretspec" @@ -51,7 +55,7 @@ report.secrets.each { |s| puts [s.name, s.status, s.required].join(" ") } ## Building -The extension links the `secretspec-ffi` archive statically. In a development +The extension links the `libsecretspec` archive statically. In a development checkout: ```bash @@ -64,7 +68,7 @@ Install one library type with [cargo-c](https://github.com/lu-zero/cargo-c): ```bash # Use "static" (the default) or "shared"; use separate prefixes for both. -bash secretspec-ffi/scripts/cinstall.sh "$PREFIX" static +bash libsecretspec/scripts/cinstall.sh "$PREFIX" static ``` Then use the same extension flag for either type: diff --git a/secretspec-rb/ext/secretspec/extconf.rb b/secretspec-rb/ext/secretspec/extconf.rb index c77e1571d..328115c1e 100644 --- a/secretspec-rb/ext/secretspec/extconf.rb +++ b/secretspec-rb/ext/secretspec/extconf.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true # Builds the secretspec native extension. By default it statically links the -# secretspec-ffi archive (libsecretspec_ffi.a) and appends the archive's native +# libsecretspec archive (libsecretspec.a) and appends the archive's native # dependency closure captured from `rustc --print native-static-libs`. # # With --enable-pkg-config every link input instead comes from an installed -# secretspec_ffi.pc, which may select a static or shared library, and the +# libsecretspec.pc, which may select a static or shared library, and the # discovery tiers below are skipped entirely. require "mkmf" @@ -13,9 +13,9 @@ if enable_config("pkg-config", false) # mkmf routes the .pc's -l flags to $libs and the rest (-L, macOS -framework) # to $LDFLAGS. - unless pkg_config("secretspec_ffi") - abort("secretspec: pkg-config could not find secretspec_ffi; point " \ - "PKG_CONFIG_PATH at a prefix containing secretspec_ffi.pc") + unless pkg_config("libsecretspec") + abort("secretspec: pkg-config could not find libsecretspec; point " \ + "PKG_CONFIG_PATH at a prefix containing libsecretspec.pc") end create_makefile("secretspec/secretspec_ext") @@ -33,11 +33,16 @@ def find_staticlib(vendor, repo_root) env = ENV["SECRETSPEC_FFI_STATICLIB"] return env if env && !env.empty? && File.exist?(env) - bundled = File.join(vendor, "libsecretspec_ffi.a") - return bundled if File.exist?(bundled) + bundled = %w[libsecretspec.a libsecretspec_ffi.a] + .map { |name| File.join(vendor, name) } + .find { |candidate| File.exist?(candidate) } + return bundled if bundled %w[release debug] - .map { |p| File.join(repo_root, "target", p, "libsecretspec_ffi.a") } + .flat_map do |profile| + %w[libsecretspec.a libsecretspec_ffi.a] + .map { |name| File.join(repo_root, "target", profile, name) } + end .select { |c| File.exist?(c) } .max_by { |c| File.mtime(c) } end @@ -51,12 +56,12 @@ def find_native_libs(vendor, repo_root) manifest = File.join(vendor, "native-static-libs.txt") return File.read(manifest).strip if File.exist?(manifest) - note = `cd #{repo_root} && cargo rustc -q -p secretspec-ffi --crate-type staticlib -- --print native-static-libs 2>&1` + note = `cd #{repo_root} && cargo rustc -q -p libsecretspec --crate-type staticlib -- --print native-static-libs 2>&1` note[/native-static-libs:\s*(.*)/, 1].to_s.strip end staticlib = find_staticlib(vendor, repo_root) -abort("secretspec: could not locate libsecretspec_ffi.a; set SECRETSPEC_FFI_STATICLIB") unless staticlib +abort("secretspec: could not locate libsecretspec.a; set SECRETSPEC_FFI_STATICLIB") unless staticlib # Header: explicit contract, the bundled vendor copy (platform gem), or the # ffi crate's include dir. @@ -66,7 +71,7 @@ def find_native_libs(vendor, repo_root) elsif File.exist?(File.join(vendor, "secretspec.h")) vendor else - File.join(repo_root, "secretspec-ffi", "include") + File.join(repo_root, "libsecretspec", "include") end $INCFLAGS << " -I#{include_dir}" diff --git a/secretspec-rb/ext/secretspec/secretspec_ext.c b/secretspec-rb/ext/secretspec/secretspec_ext.c index 2cade72f9..5d89bbdbf 100644 --- a/secretspec-rb/ext/secretspec/secretspec_ext.c +++ b/secretspec-rb/ext/secretspec/secretspec_ext.c @@ -1,8 +1,8 @@ /* * Native glue for the secretspec Ruby SDK. * - * A thin C extension that statically links the secretspec-ffi archive - * (libsecretspec_ffi.a) and exposes its three C ABI functions to Ruby as + * A thin C extension that statically links the libsecretspec archive + * (libsecretspec.a) and exposes its three C ABI functions to Ruby as * Secretspec::Native.c_resolve / c_abi_version. The Rust resolver is embedded in * this extension object, so there is no separate cdylib to ship or dlopen. */ diff --git a/secretspec-rb/lib/secretspec.rb b/secretspec-rb/lib/secretspec.rb index 1d2d6102f..789d538ab 100644 --- a/secretspec-rb/lib/secretspec.rb +++ b/secretspec-rb/lib/secretspec.rb @@ -2,7 +2,7 @@ # Ruby SDK for SecretSpec, a declarative secrets manager. # -# A thin client over the secretspec-ffi C ABI. The Rust resolver is statically +# A thin client over the libsecretspec C ABI. The Rust resolver is statically # linked into a native extension (secretspec_ext), so the SDK inherits every # provider with no Ruby-side logic and there is nothing to locate at runtime. # Mirrors the Rust derive crate's vocabulary. @@ -17,7 +17,7 @@ require "secretspec/secretspec_ext" module Secretspec - # Response wire-format version this SDK understands. Tracks secretspec-ffi's + # Response wire-format version this SDK understands. Tracks libsecretspec's # RESOLVE_SCHEMA_VERSION; a mismatch means the loaded library is incompatible. RESOLVE_SCHEMA_VERSION = 2 @@ -260,7 +260,7 @@ def parse_response(payload, kind, expected_version) unless version == expected_version raise Error.new("version", "unsupported #{kind} schema version #{version} " \ - "(expected #{expected_version}); the secretspec-ffi " \ + "(expected #{expected_version}); the libsecretspec " \ "library and this SDK are out of sync") end diff --git a/secretspec-rb/scripts/build-ext.sh b/secretspec-rb/scripts/build-ext.sh index 56988c739..aa5b0ca6f 100644 --- a/secretspec-rb/scripts/build-ext.sh +++ b/secretspec-rb/scripts/build-ext.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # # Compile the secretspec native extension (statically linking -# libsecretspec_ffi.a) and place it on the SDK's load path for dev and tests. +# libsecretspec.a) and place it on the SDK's load path for dev and tests. # extconf.rb honors the SECRETSPEC_FFI_STATICLIB / SECRETSPEC_FFI_NATIVE_LIBS / # SECRETSPEC_FFI_INCLUDE contract (exported by scripts/ci-sdks.sh); otherwise it # builds and locates the debug archive from the Cargo target dir. Arguments are # forwarded to extconf.rb: pass --enable-pkg-config to read every link input -# from secretspec_ffi.pc (via PKG_CONFIG_PATH) instead. +# from libsecretspec.pc (via PKG_CONFIG_PATH) instead. set -euo pipefail pkg_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -14,7 +14,7 @@ repo_root="$(cd "$pkg_dir/.." && pwd)" # With pkg-config the .pc names an already-installed library; nothing to build. if [ -z "${SECRETSPEC_FFI_STATICLIB:-}" ] && [[ " $* " != *" --enable-pkg-config "* ]]; then - cargo build -p secretspec-ffi --manifest-path "$repo_root/Cargo.toml" + cargo build -p libsecretspec --manifest-path "$repo_root/Cargo.toml" fi ext_dir="$pkg_dir/ext/secretspec" diff --git a/secretspec-rb/scripts/stage-staticlib.sh b/secretspec-rb/scripts/stage-staticlib.sh index f8331dc13..c6a5d8277 100644 --- a/secretspec-rb/scripts/stage-staticlib.sh +++ b/secretspec-rb/scripts/stage-staticlib.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Stage the secretspec-ffi staticlib (release) into vendor/ so a platform gem +# Stage the libsecretspec staticlib (release) into vendor/ so a platform gem # build bundles it: the archive, the C header, and the archive's transitive # native deps. `gem install` then compiles only the tiny C glue and links the # bundled archive. Run before `gem build`. @@ -11,7 +11,7 @@ repo_root="$(cd "$pkg_dir/.." && pwd)" # Crate-type override: the gem only ships the staticlib, so skip the crate's # other types (on windows-gnu this avoids linking the unused cdylib entirely). -cargo rustc -p secretspec-ffi --release --manifest-path "$repo_root/Cargo.toml" \ +cargo rustc -p libsecretspec --release --manifest-path "$repo_root/Cargo.toml" \ --crate-type staticlib # The trailing sed unescapes the JSON-escaped backslashes a Windows target @@ -24,9 +24,9 @@ target_dir="$(cargo metadata --no-deps --format-version 1 --manifest-path "$repo out_dir="$target_dir/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" mkdir -p "$pkg_dir/vendor" -cp "$out_dir/libsecretspec_ffi.a" "$pkg_dir/vendor/libsecretspec_ffi.a" -cp "$repo_root/secretspec-ffi/include/secretspec.h" "$pkg_dir/vendor/secretspec.h" -cargo rustc -q -p secretspec-ffi --release --manifest-path "$repo_root/Cargo.toml" \ +cp "$out_dir/libsecretspec.a" "$pkg_dir/vendor/libsecretspec.a" +cp "$repo_root/libsecretspec/include/secretspec.h" "$pkg_dir/vendor/secretspec.h" +cargo rustc -q -p libsecretspec --release --manifest-path "$repo_root/Cargo.toml" \ --crate-type staticlib -- --print native-static-libs 2>&1 \ | sed -n 's/^note: native-static-libs: //p' | tail -1 > "$pkg_dir/vendor/native-static-libs.txt" @@ -38,4 +38,4 @@ if [[ "${CARGO_BUILD_TARGET:-}" == *-windows-gnu ]]; then "$pkg_dir/vendor/native-static-libs.txt" "$pkg_dir/vendor" fi -echo "staged libsecretspec_ffi.a + secretspec.h + native-static-libs.txt into vendor/" +echo "staged libsecretspec.a + secretspec.h + native-static-libs.txt into vendor/" diff --git a/secretspec-rb/secretspec.gemspec b/secretspec-rb/secretspec.gemspec index 89a33e949..0a2223387 100644 --- a/secretspec-rb/secretspec.gemspec +++ b/secretspec-rb/secretspec.gemspec @@ -5,7 +5,7 @@ Gem::Specification.new do |spec| spec.version = "0.19.1" spec.summary = "A declarative interface for every secret provider. Ruby SDK." spec.description = "Ruby bindings for SecretSpec: a native extension that " \ - "statically links the secretspec-ffi C ABI." + "statically links the libsecretspec C ABI." spec.authors = ["Cachix"] spec.license = "Apache-2.0" spec.homepage = "https://secretspec.dev/" @@ -16,10 +16,10 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" # The extension compiles a tiny C glue at `gem install` and statically links - # the prebuilt libsecretspec_ffi.a staged into vendor/ (see + # the prebuilt libsecretspec.a staged into vendor/ (see # scripts/stage-staticlib.sh). The archive is platform-specific, so build a # platform gem when it is present; one such gem serves every Ruby ABI. - staged = File.exist?("vendor/libsecretspec_ffi.a") + staged = File.exist?("vendor/libsecretspec.a") if staged platform = Gem::Platform.new(Gem::Platform.local) platform.version = nil if platform.os == "darwin" diff --git a/secretspec-rb/test/test_codegen.rb b/secretspec-rb/test/test_codegen.rb index 503ebe9c6..dd288b7e2 100644 --- a/secretspec-rb/test/test_codegen.rb +++ b/secretspec-rb/test/test_codegen.rb @@ -20,7 +20,7 @@ def npx? # resolver, statically linked). Returns the CLI path; the SDK loads the resolver # from the compiled extension, not a runtime library. def build_artifacts - unless system("cargo", "build", "-p", "secretspec-ffi", "-p", "secretspec", chdir: REPO) + unless system("cargo", "build", "-p", "libsecretspec", "-p", "secretspec", chdir: REPO) raise "cargo build failed" end pkg = File.expand_path("..", __dir__) diff --git a/secretspec-rb/test/test_resolve.rb b/secretspec-rb/test/test_resolve.rb index d88b61dc2..0c244b628 100644 --- a/secretspec-rb/test/test_resolve.rb +++ b/secretspec-rb/test/test_resolve.rb @@ -4,7 +4,7 @@ require "tmpdir" require "minitest/autorun" -# Compile the native extension (statically linking libsecretspec_ffi.a) unless it +# Compile the native extension (statically linking libsecretspec.a) unless it # is already built. ci-sdks.sh builds it explicitly; this covers standalone runs. def ensure_ext pkg = File.expand_path("..", __dir__) diff --git a/secretspec-swift/README.md b/secretspec-swift/README.md index 5ad1aa761..2d672a07f 100644 --- a/secretspec-swift/README.md +++ b/secretspec-swift/README.md @@ -6,6 +6,9 @@ The `SecretSpec` Swift package resolves the same `secretspec.toml` manifests as the CLI and every other SDK. It supports macOS 12 or later on Intel and Apple silicon; the SwiftPM package includes the Rust resolver in an XCFramework. +> The embedded ABI packaged by the XCFramework is named `libsecretspec` in +> SecretSpec 0.20+. It was named `secretspec-ffi` through 0.19. + ```swift import SecretSpec @@ -29,11 +32,11 @@ Build the Rust cdylib on macOS, turn it into the local XCFramework, and run the Swift tests: ```bash -cargo build -p secretspec-ffi +cargo build -p libsecretspec mkdir -p secretspec-swift/Artifacts bash scripts/build-swift-xcframework.sh \ secretspec-swift/Artifacts/CSecretSpec.xcframework \ - target/debug/libsecretspec_ffi.dylib + target/debug/libsecretspec.dylib swift test ``` diff --git a/secretspec-swift/Sources/SecretSpec/SecretSpec.swift b/secretspec-swift/Sources/SecretSpec/SecretSpec.swift index 9cec97341..1b6bc1e7a 100644 --- a/secretspec-swift/Sources/SecretSpec/SecretSpec.swift +++ b/secretspec-swift/Sources/SecretSpec/SecretSpec.swift @@ -232,7 +232,7 @@ public struct SecretSpecBuilder: Sendable { throw SecretSpecError( kind: "version", message: "unsupported \(kind) schema version \(actual) " - + "(expected \(expected)); the secretspec-ffi library " + + "(expected \(expected)); the libsecretspec library " + "and this SDK are out of sync" ) } diff --git a/secretspec-swift/ffi/secretspec.h b/secretspec-swift/ffi/secretspec.h index 78fedf22f..b13367fe1 100644 --- a/secretspec-swift/ffi/secretspec.h +++ b/secretspec-swift/ffi/secretspec.h @@ -1,8 +1,8 @@ /* * Development-only forwarding header for the checked-in module map. * - * The release XCFramework places the canonical secretspec-ffi header beside + * The release XCFramework places the canonical libsecretspec header beside * that module map; keep a relative include here so local Clang/Swift tooling * can validate the same module without duplicating the ABI declaration. */ -#include "../../secretspec-ffi/include/secretspec.h" +#include "../../libsecretspec/include/secretspec.h" diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index 7bb1eb71e..9816326c9 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -57,7 +57,9 @@ aws-sdk-ssm = { workspace = true, optional = true } azure_core = { workspace = true, optional = true } azure_identity = { workspace = true, optional = true } azure_security_keyvault_secrets = { workspace = true, optional = true } -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "process", "sync", "time"] } +secretspec-ipc.workspace = true +async-trait.workspace = true reqwest = { workspace = true, optional = true } rand.workspace = true rsa.workspace = true @@ -71,6 +73,16 @@ age = { workspace = true, optional = true } libc.workspace = true signal-hook.workspace = true +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } + [features] default = ["cli", "keyring", "kdbx", "keeper", "gcsm", "awssm", "awsps", "vault", "openbao", "bws", "akv", "aac", "infisical", "bw", "age", "scaleway", "sops"] cli = ["dep:clap_complete", "dep:clap_complete_nushell", "dep:is_executable", "dep:dunce"] diff --git a/secretspec/README.md b/secretspec/README.md index 1ee756980..48b3e3a45 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -276,8 +276,13 @@ Beyond Rust, SecretSpec ships SDKs for other languages. Each is a thin client over the same native core, so every provider, chain, profile, and generator works identically with no per-language resolution logic: +The shared embedded ABI is named `libsecretspec` in SecretSpec 0.20+; it was +named `secretspec-ffi` through 0.19. This embedded library is separate from the +`libsecretspec-resolver` C client and `secretspec-ipc` Rust implementation used for +out-of-process client and provider communication in 0.20+. + - [Python](https://secretspec.dev/sdk/python) (via a pyo3 extension) -- [Go](https://secretspec.dev/sdk/go) (via purego, no cgo, over the `secretspec-ffi` C ABI) +- [Go](https://secretspec.dev/sdk/go) (via purego, no cgo, over the `libsecretspec` C ABI) - [Ruby](https://secretspec.dev/sdk/ruby) (via a native C extension) - [Node.js / TypeScript](https://secretspec.dev/sdk/nodejs) (napi-rs addon) - [Haskell](https://secretspec.dev/sdk/haskell) (build-time FFI link) @@ -320,6 +325,7 @@ secretspec run -- command # Run command with secrets as env vars # Inspect access secretspec audit # Show the local audit log of secret access +secretspec serve # Private SDK resolver session (0.20+) # Enable contextual Fish completions for this session (0.20+) secretspec completions fish | source diff --git a/secretspec/src/audit.rs b/secretspec/src/audit.rs index 60698632f..f6a58b576 100644 --- a/secretspec/src/audit.rs +++ b/secretspec/src/audit.rs @@ -142,8 +142,22 @@ pub(crate) struct AuditContext<'a> { pub reference: Option, pub outcome: AuditOutcome, pub error_kind: Option<&'a str>, + pub interaction: Option<&'a secretspec_ipc::InteractionReference>, pub reason: Option<&'a str>, pub caller: Option<&'a CallerContext>, + /// Structured caller context supplied by resolver-mode clients (0.20+). + /// It is audit attribution only, never identity or authorization input. + pub purpose: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub(crate) struct AuditPurpose<'a> { + pub consumer: &'a str, + pub operation: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option<&'a str>, } /// One serialized audit record (one JSON Lines entry). @@ -182,11 +196,18 @@ struct AuditEvent<'a> { outcome: AuditOutcome, #[serde(skip_serializing_if = "Option::is_none")] error_kind: Option<&'a str>, + /// Opaque provider interaction correlation, never authorization material + /// (SecretSpec 0.20+). + #[serde(skip_serializing_if = "Option::is_none")] + interaction: Option<&'a secretspec_ipc::InteractionReference>, #[serde(skip_serializing_if = "Option::is_none")] reason: Option<&'a str>, /// Caller-asserted software integration metadata (SecretSpec 0.20+). #[serde(skip_serializing_if = "Option::is_none")] caller: Option<&'a CallerContext>, + /// Resolver caller context (SecretSpec 0.20+). + #[serde(skip_serializing_if = "Option::is_none")] + purpose: Option>, actor: &'a Actor, /// secretspec version that produced the event. version: &'static str, @@ -412,8 +433,10 @@ impl AuditLogger { reference: ctx.reference.as_deref(), outcome: ctx.outcome, error_kind: ctx.error_kind, + interaction: ctx.interaction, reason: ctx.reason, caller: ctx.caller, + purpose: ctx.purpose, actor: &self.actor, version: env!("CARGO_PKG_VERSION"), }; @@ -663,6 +686,7 @@ mod tests { reference: None, outcome: AuditOutcome::Found, error_kind: None, + interaction: None, reason: Some("deploy web frontend"), caller: Some( &CallerContext::new("git") @@ -670,6 +694,12 @@ mod tests { .with_operation("credential_get") .with_resource("github.com"), ), + purpose: Some(AuditPurpose { + consumer: "python-sdk", + operation: "resolve", + host: None, + path: Some("/service"), + }), }, ); @@ -688,6 +718,9 @@ mod tests { assert_eq!(event["caller"]["version"], "2.51.0"); assert_eq!(event["caller"]["operation"], "credential_get"); assert_eq!(event["caller"]["resource"], "github.com"); + assert_eq!(event["purpose"]["consumer"], "python-sdk"); + assert_eq!(event["purpose"]["operation"], "resolve"); + assert_eq!(event["purpose"]["path"], "/service"); assert_eq!(event["session_id"], "test-session"); assert_eq!(event["seq"], 0); // Provider credentials (the `:password`) are redacted; the username, @@ -697,6 +730,44 @@ mod tests { assert!(!lines[0].contains("s3cr3t")); } + #[test] + fn records_opaque_provider_interaction_correlation() { + let sink = CollectSink::default(); + let logger = AuditLogger::for_test(Box::new(sink.clone())); + let interaction = secretspec_ipc::InteractionReference::authorization( + "apr_7K3M", + Some(1_786_766_405_000), + ); + logger.record( + AuditAction::Get, + AuditContext { + project: "demo", + profile: "production", + scope: None, + key: Some("DATABASE_URL"), + keys: &[], + command: None, + provider_uri: Some("factorseal://default".to_owned()), + reference: None, + outcome: AuditOutcome::Error, + error_kind: Some("interaction_required"), + interaction: Some(&interaction), + reason: Some("deploy"), + caller: None, + purpose: None, + }, + ); + + let lines = sink.lines.lock().unwrap(); + let event: serde_json::Value = serde_json::from_str(&lines[0]).unwrap(); + assert_eq!(event["interaction"]["kind"], "authorization"); + assert_eq!(event["interaction"]["id"], "apr_7K3M"); + assert_eq!( + event["interaction"]["expires_at_unix_ms"], + 1_786_766_405_000_u64 + ); + } + #[test] fn bulk_event_records_keys_and_command() { let sink = CollectSink::default(); @@ -716,8 +787,10 @@ mod tests { reference: None, outcome: AuditOutcome::Found, error_kind: None, + interaction: None, reason: None, caller: None, + purpose: None, }, ); @@ -750,8 +823,10 @@ mod tests { reference: None, outcome: AuditOutcome::Written, error_kind: None, + interaction: None, reason: None, caller: None, + purpose: None, }, ); } @@ -909,8 +984,10 @@ mod tests { reference: None, outcome: AuditOutcome::Found, error_kind: None, + interaction: None, reason: None, caller: None, + purpose: None, }, ); diff --git a/secretspec/src/cache.rs b/secretspec/src/cache.rs index d3f21f646..b049c803a 100644 --- a/secretspec/src/cache.rs +++ b/secretspec/src/cache.rs @@ -32,6 +32,10 @@ struct CacheEnvelope { expires_at: u64, max_age_secs: u64, route_fingerprint: String, + /// Authoritative validity bound of the cached secret itself. This is + /// independent of `expires_at`, which is the cache entry's freshness bound. + #[serde(default)] + secret_expires_at_unix_ms: Option, /// The cached plaintext stays in a zeroizing buffer on both serialization /// and deserialization. #[serde(with = "zeroizing_string")] @@ -96,7 +100,11 @@ pub(crate) enum CacheOwnership { /// What a stored cache entry can do for the read that found it. pub(crate) enum CacheEntryStatus { /// Fresh, and written for the expected authoritative route. - Fresh(SecretString), + Fresh { + value: SecretString, + refresh_at_unix_ms: Option, + expires_at_unix_ms: Option, + }, /// Expired (regardless of owner), or ours but no longer usable because its /// authoritative route or freshness policy changed. Stale, @@ -115,6 +123,8 @@ pub(crate) enum CacheEncodeError { Clock(#[from] std::time::SystemTimeError), #[error("cache expiration timestamp is too large")] ExpirationOverflow, + #[error("the secret has already expired")] + SecretExpired, #[error(transparent)] Serialize(#[from] serde_json::Error), } @@ -221,9 +231,14 @@ fn inspect_entry_with_clock( if envelope.cached_at > now || now.saturating_sub(envelope.cached_at) > max_age_secs { return Ok(CacheEntryStatus::Stale); } - return Ok(CacheEntryStatus::Fresh(SecretString::new( - envelope.value.as_str().into(), - ))); + return Ok(CacheEntryStatus::Fresh { + value: SecretString::new(envelope.value.as_str().into()), + refresh_at_unix_ms: envelope + .cached_at + .checked_add(max_age_secs) + .and_then(|expires_at| expires_at.checked_mul(1000)), + expires_at_unix_ms: None, + }); } Err(_) => return Ok(CacheEntryStatus::OursUnreadable), }; @@ -250,9 +265,17 @@ fn inspect_entry_with_clock( if envelope.route_fingerprint != route_fingerprint { return Ok(CacheEntryStatus::Stale); } - Ok(CacheEntryStatus::Fresh(SecretString::new( - envelope.value.as_str().into(), - ))) + if envelope + .secret_expires_at_unix_ms + .is_some_and(|expires_at| now.saturating_mul(1000) >= expires_at) + { + return Ok(CacheEntryStatus::Stale); + } + Ok(CacheEntryStatus::Fresh { + value: SecretString::new(envelope.value.as_str().into()), + refresh_at_unix_ms: envelope.expires_at.checked_mul(1000), + expires_at_unix_ms: envelope.secret_expires_at_unix_ms, + }) } #[cfg(test)] @@ -282,6 +305,7 @@ pub(crate) fn encode_entry( max_age_secs: u64, route_fingerprint: String, value: &SecretString, + secret_expires_at_unix_ms: Option, ) -> Result { encode_entry_at( project, @@ -290,6 +314,7 @@ pub(crate) fn encode_entry( max_age_secs, route_fingerprint, value, + secret_expires_at_unix_ms, ) } @@ -300,16 +325,28 @@ fn encode_entry_at( max_age_secs: u64, route_fingerprint: String, value: &SecretString, + secret_expires_at_unix_ms: Option, ) -> Result { - let expires_at = now + let cache_expires_at = now .checked_add(max_age_secs) .ok_or(CacheEncodeError::ExpirationOverflow)?; + let expires_at = match secret_expires_at_unix_ms { + Some(secret_expiry) => { + let secret_expiry_secs = secret_expiry / 1000; + if secret_expiry_secs <= now { + return Err(CacheEncodeError::SecretExpired); + } + cache_expires_at.min(secret_expiry_secs) + } + None => cache_expires_at, + }; let envelope = CacheEnvelope { project: project.to_string(), profile: profile.to_string(), expires_at, max_age_secs, route_fingerprint, + secret_expires_at_unix_ms, value: Zeroizing::new(value.expose_secret().to_string()), }; // Both plaintext renderings of the envelope are held in buffers that @@ -338,6 +375,7 @@ mod tests { MAX_AGE, FINGERPRINT.to_string(), &SecretString::new("sensitive".into()), + None, ) .expect("cache envelope serializes") } @@ -358,11 +396,18 @@ mod tests { MAX_AGE, EXPIRES_AT - 1, ); - let CacheEntryStatus::Fresh(value) = status else { + let CacheEntryStatus::Fresh { + value, + refresh_at_unix_ms, + expires_at_unix_ms, + } = status + else { panic!("an entry is fresh before its expiration timestamp"); }; assert_eq!(envelope.expires_at, EXPIRES_AT); assert_eq!(envelope.max_age_secs, MAX_AGE); + assert_eq!(refresh_at_unix_ms, Some(EXPIRES_AT * 1000)); + assert_eq!(expires_at_unix_ms, None); assert_eq!(value.expose_secret(), "sensitive"); } @@ -374,6 +419,50 @@ mod tests { )); } + #[test] + fn secret_expiry_is_preserved_and_caps_cache_freshness() { + let secret_expiry_ms = (WRITTEN_AT + 20) * 1000 + 500; + let entry = encode_entry_at( + PROJECT, + PROFILE, + WRITTEN_AT, + MAX_AGE, + FINGERPRINT.to_string(), + &SecretString::new("sensitive".into()), + Some(secret_expiry_ms), + ) + .expect("unexpired secret can be cached"); + + let CacheEntryStatus::Fresh { + refresh_at_unix_ms, + expires_at_unix_ms, + .. + } = inspect_entry_at( + &entry, + PROJECT, + PROFILE, + FINGERPRINT, + MAX_AGE, + WRITTEN_AT + 19, + ) + else { + panic!("entry is fresh before the capped cache boundary"); + }; + assert_eq!(refresh_at_unix_ms, Some((WRITTEN_AT + 20) * 1000)); + assert_eq!(expires_at_unix_ms, Some(secret_expiry_ms)); + assert!(matches!( + inspect_entry_at( + &entry, + PROJECT, + PROFILE, + FINGERPRINT, + MAX_AGE, + WRITTEN_AT + 20, + ), + CacheEntryStatus::Stale + )); + } + #[test] fn clock_rollback_makes_an_implausibly_distant_expiration_stale() { assert!(matches!( @@ -427,6 +516,7 @@ mod tests { MAX_AGE, FINGERPRINT.to_string(), &SecretString::new("sensitive".into()), + None, ), Err(CacheEncodeError::ExpirationOverflow) )); @@ -518,9 +608,16 @@ mod tests { fn fresh_legacy_entry_remains_usable_during_migration() { let legacy = legacy_entry(); let status = inspect_entry_at(&legacy, PROJECT, PROFILE, FINGERPRINT, MAX_AGE, EXPIRES_AT); - let CacheEntryStatus::Fresh(value) = status else { + let CacheEntryStatus::Fresh { + value, + refresh_at_unix_ms, + expires_at_unix_ms, + } = status + else { panic!("v2 preserves its original inclusive freshness boundary"); }; + assert_eq!(refresh_at_unix_ms, Some(EXPIRES_AT * 1000)); + assert_eq!(expires_at_unix_ms, None); assert_eq!(value.expose_secret(), "sensitive"); } diff --git a/secretspec/src/cli/mod.rs b/secretspec/src/cli/mod.rs index d1f7bf457..d689f0640 100644 --- a/secretspec/src/cli/mod.rs +++ b/secretspec/src/cli/mod.rs @@ -13,12 +13,115 @@ use std::io::{IsTerminal, Write}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; mod completion; mod git; use git::GitAction; +struct LoginCredentialBroker { + app: Arc, + alias: String, + configured: HashMap, + request_lock: Mutex<()>, + values: Mutex>, + stored: Mutex>, +} + +impl LoginCredentialBroker { + fn new( + app: Arc, + alias: String, + credentials: Vec<(String, crate::config::CredentialSource)>, + ) -> Self { + Self { + app, + alias, + configured: credentials.into_iter().collect(), + request_lock: Mutex::new(()), + values: Mutex::new(HashMap::new()), + stored: Mutex::new(Vec::new()), + } + } +} + +impl crate::provider::external::ProviderCredentialBroker for LoginCredentialBroker { + fn get( + &self, + scheme: &str, + request: &secretspec_ipc::protocol::callback::CredentialParams, + ) -> crate::Result> { + let _request = self + .request_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let source = self.configured.get(&request.name); + let key = ( + scheme.to_string(), + if source.is_some() { + String::new() + } else { + request.scope.clone() + }, + request.name.clone(), + ); + if let Some(value) = self + .values + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + .cloned() + { + return Ok(Some(value)); + } + let prompt = match source { + Some(source) => format!( + "Enter {} for provider '{}' (source: {}):", + request.name, + self.alias, + source.display_provider() + ), + None => format!( + "Enter {} for provider '{}' ({} credential):", + request.name, self.alias, scheme + ), + }; + let entered = inquire::Password::new(&prompt) + .without_confirmation() + .prompt() + .map_err(|_| { + crate::SecretSpecError::ProviderOperationFailed( + "provider credential prompt failed".to_string(), + ) + })?; + if entered.is_empty() { + return Ok(None); + } + let value = secrecy::SecretString::new(entered.into()); + let location = match source { + Some(source) => self + .app + .store_provider_credential(source, &request.name, &value)?, + None => self.app.store_external_provider_credential( + scheme, + &request.scope, + &request.name, + &value, + )?, + }; + self.values + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, value.clone()); + self.stored + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((request.name.clone(), location)); + Ok(Some(value)) + } +} + /// Main CLI structure for the secretspec application. /// /// This is the entry point for the command-line interface, parsing user commands @@ -296,6 +399,19 @@ enum Commands { #[command(subcommand)] action: CacheAction, }, + /// Serve one `secretspec.resolver/1` session over stdin and stdout (0.20+) + /// + /// The session is a private child of whoever launched it: it exchanges + /// framed IPC on the standard streams, never prompts on them, and exits + /// with its parent. A future daemon mode would instead expose a socket + /// other local processes can reach, so that mode has to be asked for while + /// this one does not. + Serve { + /// Advertise resolution only, refusing `resolver.set` and + /// `resolver.delete` (0.20+) + #[arg(long)] + read_only: bool, + }, /// Show the local audit log of secret access Audit { /// Only show entries for this project @@ -1264,12 +1380,36 @@ pub fn main() -> Result<()> { Ok(()) } ProviderAction::Login { name } => { - let app = load_secrets(&cli.file, &cli.reason, &caller)?; + let app = Arc::new(load_secrets(&cli.file, &cli.reason, &caller)?); let credentials = app.declared_provider_credentials(&name).into_diagnostic()?; - let provider_reads = crate::provider::spec_provider_reads( - &app.resolve_provider_spec(name.clone()), - ); + let resolved = app.resolve_provider_spec(name.clone()); + if crate::provider::spec_uses_dynamic_credentials(&resolved) + .into_diagnostic()? + { + let broker = Arc::new(LoginCredentialBroker::new( + app.clone(), + name.clone(), + credentials, + )); + app.initialize_external_provider_with_broker(&name, broker.clone()) + .into_diagnostic()?; + let stored = broker + .stored + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if stored.is_empty() { + println!( + "Provider alias '{name}' requested no SecretSpec-managed credentials." + ); + } else { + for (credential_name, location) in stored.iter() { + println!("✓ stored {credential_name} in {location}"); + } + } + return Ok(()); + } + let provider_reads = crate::provider::spec_provider_reads(&resolved); if credentials.is_empty() { println!("Provider alias '{name}' declares no credentials."); return Ok(()); @@ -1590,6 +1730,11 @@ pub fn main() -> Result<()> { Ok(()) } }, + Commands::Serve { read_only } => { + crate::provider::block_on(crate::serve::run_stdio(read_only)) + .into_diagnostic() + .wrap_err("SecretSpec resolver failed") + } // Show the local audit log Commands::Audit { project, diff --git a/secretspec/src/config.rs b/secretspec/src/config.rs index 4671c4045..1e98b0719 100644 --- a/secretspec/src/config.rs +++ b/secretspec/src/config.rs @@ -1031,6 +1031,12 @@ impl ConfigGraphLoader { Ok(merged) } + #[cfg(feature = "cli")] + fn load_inline(content: &str, base_dir: &Path) -> Result { + let root = Config::parse_document(content)?; + Config::from_root_in(root, base_dir) + } + fn visit_extends(&mut self, config: &Config, base_dir: &Path) -> Result<(), ParseError> { for extend_path in config.project.extends.iter().flatten() { let joined_path = base_dir.join(extend_path); @@ -1095,6 +1101,13 @@ impl FromStr for Config { } } +impl Config { + #[cfg(feature = "cli")] + pub(crate) fn from_inline(content: &str, base_dir: &Path) -> Result { + ConfigGraphLoader::load_inline(content, base_dir) + } +} + impl TryFrom<&Path> for Config { type Error = ParseError; diff --git a/secretspec/src/error.rs b/secretspec/src/error.rs index 8091871b3..d85dc5a79 100644 --- a/secretspec/src/error.rs +++ b/secretspec/src/error.rs @@ -70,6 +70,10 @@ pub enum SecretSpecError { PromptUnavailable(String), #[error("Prompted value for secret '{0}' cannot be empty")] PromptValueEmpty(String), + #[error( + "Secret '{0}' would be produced and stored, and this session may not write to a provider" + )] + ProducedValueWriteRefused(String), #[error( "Composed secret '{0}' is derived from other secrets and has no stored value to change" )] @@ -88,6 +92,11 @@ pub enum SecretSpecError { NoProjectName, #[error("Provider operation failed: {0}")] ProviderOperationFailed(String), + #[error("Provider protocol error: {kind}")] + ProviderProtocol { + kind: secretspec_ipc::ErrorKind, + interaction: Option, + }, #[error("User interaction error: {0}")] InquireError(#[from] inquire::InquireError), #[error("JSON error: {0}")] @@ -139,6 +148,7 @@ impl SecretSpecError { SecretSpecError::RequiredSecretMissing(_) => "required_secret_missing", SecretSpecError::PromptUnavailable(_) => "prompt_unavailable", SecretSpecError::PromptValueEmpty(_) => "prompt_value_empty", + SecretSpecError::ProducedValueWriteRefused(_) => "produced_value_write_refused", SecretSpecError::ComposedSecretReadOnly(_) => "composed_secret_read_only", SecretSpecError::ExtractedSecretReadOnly(_) => "extracted_secret_read_only", SecretSpecError::CompositionFailed(_) => "composition_failed", @@ -146,6 +156,7 @@ impl SecretSpecError { SecretSpecError::ExtendedConfigNotFound(_) => "extended_config_not_found", SecretSpecError::NoProjectName => "no_project_name", SecretSpecError::ProviderOperationFailed(_) => "provider_operation_failed", + SecretSpecError::ProviderProtocol { kind, .. } => kind.as_str(), SecretSpecError::InquireError(_) => "inquire", SecretSpecError::Json(_) => "json", SecretSpecError::InvalidProfile(_) => "invalid_profile", @@ -157,6 +168,15 @@ impl SecretSpecError { SecretSpecError::ReasonRequired => "reason_required", } } + + /// Opaque pending interaction associated with a provider failure, when + /// the provider supplied one (SecretSpec 0.20+). + pub fn interaction(&self) -> Option<&secretspec_ipc::InteractionReference> { + match self { + Self::ProviderProtocol { interaction, .. } => interaction.as_ref(), + _ => None, + } + } } /// A type alias for `Result` @@ -269,6 +289,13 @@ mod tests { SecretSpecError::ProviderOperationFailed("nope".into()), "provider_operation_failed", ), + ( + SecretSpecError::ProviderProtocol { + kind: secretspec_ipc::ErrorKind::InteractionRequired, + interaction: None, + }, + "interaction_required", + ), ( SecretSpecError::InvalidProfile("ghost".into()), "invalid_profile", diff --git a/secretspec/src/lib.rs b/secretspec/src/lib.rs index ba7dae99f..c645bbb0d 100644 --- a/secretspec/src/lib.rs +++ b/secretspec/src/lib.rs @@ -57,9 +57,16 @@ mod plan; mod report; mod resolve; mod secrets; +#[cfg(feature = "cli")] +mod serve; mod spec; mod spec_edit; mod validation; +/// Windows ACL helpers. Public only so the IPC conformance harness can build a +/// directory the endpoint trust checks accept; not part of the stable API. +#[cfg(windows)] +#[doc(hidden)] +pub mod windows_security; pub(crate) mod provider; @@ -102,7 +109,12 @@ pub use config::{ ProviderCache, RequireReason, SecretEncoding, SecretExtract, }; pub use error::{Result, SecretSpecError}; -pub use provider::{DiscoveryContext, ProducedValuePersistence, Provider}; +pub use provider::external::{ + EndpointSecurity, ExternalProvider, PlatformEndpointSecurity, ProviderCredentialBroker, + ProviderCredentialRequest, ProviderDiscovery, ProviderEndpoint, RegistrationScope, + set_provider_discovery, +}; +pub use provider::{Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderValue}; pub use report::{ RESOLUTION_REPORT_SCHEMA_VERSION, ResolutionReport, ResolutionStatus, SecretResolution, }; diff --git a/secretspec/src/plan.rs b/secretspec/src/plan.rs index 9c0d735e9..588baf50c 100644 --- a/secretspec/src/plan.rs +++ b/secretspec/src/plan.rs @@ -669,10 +669,11 @@ impl Secrets { // route. A store that cannot delete gives an uninvalidatable cache, so // require the capability here rather than discovering it the first time // a stale value needs dropping. - if !crate::provider::spec_provider_deletes(&cache_uri) { + if !self.provider_supports_delete(cache.provider())? { return Err(SecretSpecError::ProviderOperationFailed(format!( "cached provider alias '{name}' caches into '{uri}', which cannot delete secrets, \ - so its entries could never be invalidated. Cache into one of: {supported}.", + so its entries could never be invalidated. Cache into an external provider \ + advertising `provider.delete`, or one of: {supported}.", uri = crate::audit::redact_uri_strict(&cache_uri), supported = crate::provider::deleting_provider_names().join(", "), ))); diff --git a/secretspec/src/provider/aac.rs b/secretspec/src/provider/aac.rs index bcec63395..7db2f8fd5 100644 --- a/secretspec/src/provider/aac.rs +++ b/secretspec/src/provider/aac.rs @@ -1765,7 +1765,7 @@ impl Provider for AacProvider { )) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/address.rs b/secretspec/src/provider/address.rs index f34e6226d..ab125a1bb 100644 --- a/secretspec/src/provider/address.rs +++ b/secretspec/src/provider/address.rs @@ -87,7 +87,8 @@ impl OwnedAddress { } } -/// Rejects native-address coordinates a provider has no equivalent for. +/// Builds the shared diagnostic for a native-address coordinate a provider has +/// no equivalent for. /// /// Enforced once for every address inside the default /// [`resolve_coords`](Provider::resolve_coords), against the provider's @@ -102,6 +103,7 @@ impl OwnedAddress { /// the store the ref was written for — a Bitwarden or 1Password item field, say — /// and this store simply organizes the secret differently, the fix is a /// per-provider address (0.19+), not a lossy edit to the ref. +#[allow(dead_code)] pub(super) fn reject_unsupported_coords( provider: &str, addr: &NativeAddress, @@ -113,18 +115,26 @@ pub(super) fn reject_unsupported_coords( continue; } if !supported.contains(&name) { - return Err(SecretSpecError::ProviderOperationFailed(format!( - "the {provider} provider does not support the `{name}` coordinate. \ - Drop `{name}` from the ref for `{item}`, or give this provider its \ - own address with `refs.` or an alias `ref` template (0.19+): \ - https://secretspec.dev/concepts/references/#different-coordinates-per-provider-019", - item = addr.item - ))); + return Err(unsupported_coord_error(provider, addr, name)); } } Ok(()) } +pub(super) fn unsupported_coord_error( + provider: &str, + addr: &NativeAddress, + name: &str, +) -> SecretSpecError { + SecretSpecError::ProviderOperationFailed(format!( + "the {provider} provider does not support the `{name}` coordinate. \ + Drop `{name}` from the ref for `{item}`, or give this provider its \ + own address with `refs.` or an alias `ref` template (0.19+): \ + https://secretspec.dev/concepts/references/#different-coordinates-per-provider-019", + item = addr.item + )) +} + /// Resolves an address for flat stores whose secrets have no sub-components: /// any address, convention or `ref`, names the entry via `item` alone, every /// other coordinate having been rejected by the provider's empty diff --git a/secretspec/src/provider/age.rs b/secretspec/src/provider/age.rs index fae4dd8b8..f0867deb1 100644 --- a/secretspec/src/provider/age.rs +++ b/secretspec/src/provider/age.rs @@ -373,7 +373,7 @@ impl Provider for AgeProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/akv.rs b/secretspec/src/provider/akv.rs index 4db5cdd12..b36eefcc7 100644 --- a/secretspec/src/provider/akv.rs +++ b/secretspec/src/provider/akv.rs @@ -658,7 +658,7 @@ impl Provider for AkvProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -932,7 +932,7 @@ mod tests { #[test] fn registration_advertises_service_principal_credentials() { assert_eq!( - crate::provider::credential_names_for_spec("akv://myvault"), + crate::provider::credential_names_for_spec("akv://myvault").unwrap(), &[TENANT_ID, CLIENT_ID, CLIENT_SECRET] ); } diff --git a/secretspec/src/provider/awsps.rs b/secretspec/src/provider/awsps.rs index f712001c3..fafecd32a 100644 --- a/secretspec/src/provider/awsps.rs +++ b/secretspec/src/provider/awsps.rs @@ -645,7 +645,7 @@ impl Provider for AwspsProvider { } } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/awssm.rs b/secretspec/src/provider/awssm.rs index ecddfd837..352ddb219 100644 --- a/secretspec/src/provider/awssm.rs +++ b/secretspec/src/provider/awssm.rs @@ -449,7 +449,7 @@ impl Provider for AwssmProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/bw.rs b/secretspec/src/provider/bw.rs index ca4f43913..6cc9d1bd5 100644 --- a/secretspec/src/provider/bw.rs +++ b/secretspec/src/provider/bw.rs @@ -2670,7 +2670,7 @@ impl Provider for BitwardenProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/bws.rs b/secretspec/src/provider/bws.rs index 16a57e7ac..87f1cf3cf 100644 --- a/secretspec/src/provider/bws.rs +++ b/secretspec/src/provider/bws.rs @@ -339,7 +339,7 @@ impl Provider for BwsProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/dashlane.rs b/secretspec/src/provider/dashlane.rs index 43add598d..c1361eb5d 100644 --- a/secretspec/src/provider/dashlane.rs +++ b/secretspec/src/provider/dashlane.rs @@ -585,7 +585,7 @@ impl Provider for DashlaneProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/dotenv.rs b/secretspec/src/provider/dotenv.rs index 84a27f8d2..15b99833d 100644 --- a/secretspec/src/provider/dotenv.rs +++ b/secretspec/src/provider/dotenv.rs @@ -208,7 +208,7 @@ impl Provider for DotEnvProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/env.rs b/secretspec/src/provider/env.rs index 23053d08d..33b15f75c 100644 --- a/secretspec/src/provider/env.rs +++ b/secretspec/src/provider/env.rs @@ -126,7 +126,7 @@ impl Provider for EnvProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/external.rs b/secretspec/src/provider/external.rs new file mode 100644 index 000000000..020a6329e --- /dev/null +++ b/secretspec/src/provider/external.rs @@ -0,0 +1,2045 @@ +//! External provider discovery and the `secretspec.provider/1` adapter. +//! +//! Available since SecretSpec 0.20. + +use super::{ + Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderCredentials, + ProviderUrl, ProviderValue, exists_each, get_each_with, +}; +use crate::config::NativeAddress; +use crate::{Result, Secret, SecretSpecError}; +use secrecy::{ExposeSecret, SecretString}; +use secretspec_ipc::deadline_unix_ms_after; +use secretspec_ipc::error::{ErrorKind as RpcErrorKind, RpcError}; +use secretspec_ipc::lifecycle::{CredentialResponder, Environment, LaunchOptions, ProviderSession}; +use secretspec_ipc::protocol::callback::CredentialResult; +use secretspec_ipc::protocol::provider::{ + self as wire, AddressParams, ApplicationContext, GetManyParams, GetResult, + InitializeApplication, NamedRequest, Persistence, ReflectParams, SetExpiringParams, SetParams, +}; +use secretspec_ipc::protocol::{Limits, Product}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::OsString; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock, RwLock}; +use std::time::Duration; + +const REGISTRATION_MAX_BYTES: u64 = 64 * 1024; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const OPERATION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Semantic credential request made by an external endpoint (0.20+). +pub use secretspec_ipc::protocol::callback::CredentialParams as ProviderCredentialRequest; + +/// A resolved provider endpoint and its fixed executable identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderEndpoint { + pub scheme: String, + pub executable: PathBuf, + /// Explicit embedders may select a mode directly. Filesystem-discovered + /// providers always receive the fixed `provider` argument. + #[serde(default)] + pub arguments: Vec, +} + +/// Public discovery claim written by an installed provider. +/// +/// The filename supplies the scheme and the launch contract is always +/// ` provider`. Unknown fields are deliberately ignored so future +/// SecretSpec releases can extend the claim without versioning this small +/// discovery document. +#[derive(Deserialize)] +struct ProviderClaim { + executable: PathBuf, +} + +/// Explicit inputs to external-provider discovery. +#[derive(Debug, Clone, Default)] +pub struct ProviderDiscovery { + pub explicit: BTreeMap, + pub user_directory: Option, + pub system_directory: Option, + pub allow_path: bool, +} + +/// Scope used by the injectable endpoint security policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationScope { + Explicit, + User, + System, + Path, +} + +/// Platform security seam used by discovery tests and embedders with a +/// stronger host-specific ACL policy. +pub trait EndpointSecurity: Send + Sync { + fn check_registration(&self, path: &Path, scope: RegistrationScope) -> Result<()>; + fn check_executable(&self, path: &Path, scope: RegistrationScope) -> Result<()>; + fn privileged(&self) -> bool; +} + +#[derive(Debug, Default)] +pub struct PlatformEndpointSecurity; + +impl EndpointSecurity for PlatformEndpointSecurity { + fn check_registration(&self, path: &Path, scope: RegistrationScope) -> Result<()> { + check_file_security(path, scope, false)?; + check_parent_security(path, scope) + } + + fn check_executable(&self, path: &Path, scope: RegistrationScope) -> Result<()> { + check_file_security(path, scope, true)?; + check_parent_security(path, scope) + } + + fn privileged(&self) -> bool { + is_privileged_process() + } +} + +impl ProviderDiscovery { + /// Platform registration directories with PATH discovery disabled. + pub fn platform_default() -> Self { + let (user_directory, system_directory) = platform_directories(); + Self { + explicit: BTreeMap::new(), + user_directory, + system_directory, + allow_path: false, + } + } + + pub fn resolve(&self, scheme: &str) -> Result> { + self.resolve_with_security(scheme, &PlatformEndpointSecurity) + } + + pub fn resolve_with_security( + &self, + scheme: &str, + security: &dyn EndpointSecurity, + ) -> Result> { + let search_path = std::env::var_os("PATH"); + self.resolve_with_security_and_search_path(scheme, security, search_path.as_deref()) + } + + fn resolve_with_security_and_search_path( + &self, + scheme: &str, + security: &dyn EndpointSecurity, + search_path: Option<&std::ffi::OsStr>, + ) -> Result> { + validate_scheme(scheme)?; + if let Some(endpoint) = self.explicit.get(scheme) { + return validate_endpoint( + endpoint.clone(), + scheme, + RegistrationScope::Explicit, + security, + ) + .map(Some); + } + for (directory, scope) in [ + (self.user_directory.as_deref(), RegistrationScope::User), + (self.system_directory.as_deref(), RegistrationScope::System), + ] { + let Some(directory) = directory else { continue }; + let path = directory.join(format!("{scheme}.secretspec.json")); + if path.try_exists().map_err(discovery_io)? { + return load_registration(&path, scheme, scope, security).map(Some); + } + } + if !self.allow_path || security.privileged() { + return Ok(None); + } + let executable_name = if cfg!(windows) { + format!("secretspec-provider-{scheme}.exe") + } else { + format!("secretspec-provider-{scheme}") + }; + let Some(path) = search_path + .into_iter() + .flat_map(|value| std::env::split_paths(&value).collect::>()) + .map(|directory| directory.join(&executable_name)) + .find(|candidate| candidate.is_file()) + else { + return Ok(None); + }; + validate_endpoint( + ProviderEndpoint { + scheme: scheme.to_string(), + executable: path, + arguments: vec!["provider".to_string()], + }, + scheme, + RegistrationScope::Path, + security, + ) + .map(Some) + } +} + +static ACTIVE_DISCOVERY: LazyLock> = + LazyLock::new(|| RwLock::new(ProviderDiscovery::platform_default())); + +/// Replaces the process-wide discovery inputs used by ordinary provider URI +/// construction. Embedders can use this to supply trusted direct endpoints or +/// to opt into PATH discovery. Available since SecretSpec 0.20. +pub fn set_provider_discovery(discovery: ProviderDiscovery) { + *ACTIVE_DISCOVERY + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = discovery; +} + +pub(crate) fn discover(scheme: &str) -> Result> { + // This helper is also used to distinguish provider specs from project + // aliases. Alias spelling is intentionally broader than URI schemes, so + // an alias that cannot be an external scheme is simply not discovered. + if !is_valid_scheme(scheme) { + return Ok(None); + } + ACTIVE_DISCOVERY + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .resolve(scheme) +} + +fn load_registration( + path: &Path, + scheme: &str, + scope: RegistrationScope, + security: &dyn EndpointSecurity, +) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(discovery_io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(discovery_error( + "provider registration is not a regular non-symlink file", + )); + } + if metadata.len() > REGISTRATION_MAX_BYTES { + return Err(discovery_error("provider registration exceeds 64 KiB")); + } + let mut file = File::open(path).map_err(discovery_io)?; + let opened_metadata = file.metadata().map_err(discovery_io)?; + if !same_file_metadata(&metadata, &opened_metadata) { + return Err(discovery_error( + "provider registration changed while it was opened", + )); + } + security.check_registration(path, scope)?; + let current_metadata = std::fs::symlink_metadata(path).map_err(discovery_io)?; + if current_metadata.file_type().is_symlink() + || !same_file_metadata(&metadata, ¤t_metadata) + { + return Err(discovery_error( + "provider registration changed during validation", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.by_ref() + .take(REGISTRATION_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(discovery_io)?; + if bytes.len() as u64 > REGISTRATION_MAX_BYTES { + return Err(discovery_error("provider registration exceeds 64 KiB")); + } + let claim: ProviderClaim = serde_json::from_slice(&bytes) + .map_err(|_| discovery_error("invalid provider registration"))?; + let expected_filename = format!("{scheme}.secretspec.json"); + if path.file_name().and_then(|value| value.to_str()) != Some(&expected_filename) { + return Err(discovery_error( + "provider registration filename does not match its scheme", + )); + } + validate_endpoint( + ProviderEndpoint { + scheme: scheme.to_string(), + executable: claim.executable, + arguments: vec!["provider".to_string()], + }, + scheme, + scope, + security, + ) +} + +fn validate_endpoint( + mut endpoint: ProviderEndpoint, + expected_scheme: &str, + scope: RegistrationScope, + security: &dyn EndpointSecurity, +) -> Result { + if endpoint.scheme != expected_scheme { + return Err(discovery_error( + "provider registration scheme does not match", + )); + } + validate_scheme(&endpoint.scheme)?; + if !endpoint.executable.is_absolute() { + return Err(discovery_error("provider executable must be absolute")); + } + let executable = std::fs::canonicalize(&endpoint.executable).map_err(discovery_io)?; + if !executable.is_file() { + return Err(discovery_error("provider executable is not a regular file")); + } + security.check_executable(&executable, scope)?; + endpoint.executable = executable; + Ok(endpoint) +} + +fn validate_scheme(value: &str) -> Result<()> { + if is_valid_scheme(value) { + Ok(()) + } else { + Err(discovery_error("invalid external provider scheme")) + } +} + +fn is_valid_scheme(value: &str) -> bool { + let mut chars = value.chars(); + matches!(chars.next(), Some('a'..='z')) + && chars.all(|character| matches!(character, 'a'..='z' | '0'..='9' | '-')) +} + +/// Sticky directories are safe ancestors even when world-writable: the bit +/// stops anyone but the owner renaming or deleting an entry, so `/tmp` cannot +/// be used to swap out a subtree that belongs to someone else. +#[cfg(unix)] +const STICKY_BIT: u32 = 0o1000; + +#[cfg(unix)] +fn owner_is_trusted(uid: u32, scope: RegistrationScope) -> bool { + match scope { + RegistrationScope::System => uid == 0, + RegistrationScope::Explicit | RegistrationScope::User | RegistrationScope::Path => { + uid == effective_uid() || uid == 0 + } + } +} + +#[cfg(unix)] +fn check_file_security(path: &Path, scope: RegistrationScope, executable: bool) -> Result<()> { + use std::os::unix::fs::MetadataExt; + // Resolve first, then inspect the resolved path with `symlink_metadata`. + // Plain `metadata` follows symlinks silently, so what it validated was the + // target while the registration named something else entirely. + let resolved = std::fs::canonicalize(path).map_err(discovery_io)?; + let metadata = std::fs::symlink_metadata(&resolved).map_err(discovery_io)?; + if !metadata.is_file() || metadata.mode() & 0o022 != 0 { + return Err(discovery_error(if executable { + "provider executable is group- or world-writable" + } else { + "provider registration is group- or world-writable" + })); + } + if !owner_is_trusted(metadata.uid(), scope) { + return Err(discovery_error( + "provider endpoint ownership is outside the trust domain", + )); + } + if executable && metadata.mode() & 0o111 == 0 { + return Err(discovery_error("provider executable is not executable")); + } + Ok(()) +} + +#[cfg(unix)] +fn check_parent_security(path: &Path, scope: RegistrationScope) -> Result<()> { + use std::os::unix::fs::MetadataExt; + // Every directory above the endpoint, not just the immediate parent: one + // writable ancestor lets an attacker swap a component for a symlink to any + // executable that already satisfies the checks below it. + // + // The walk runs over the canonical path, so a symlinked component is + // validated as the chain it actually resolves to rather than refused + // outright. Refusing symlinks would break the common cases where they are + // how software is installed: Nix store paths and macOS's /var. + let resolved = std::fs::canonicalize(path).map_err(discovery_io)?; + let mut checked_any = false; + for ancestor in resolved.ancestors().skip(1) { + let metadata = std::fs::symlink_metadata(ancestor).map_err(discovery_io)?; + if !metadata.is_dir() { + return Err(discovery_error( + "provider endpoint path component is not a directory", + )); + } + let mode = metadata.mode(); + if mode & 0o022 != 0 && mode & STICKY_BIT == 0 { + return Err(discovery_error( + "provider endpoint directory is group- or world-writable", + )); + } + if !owner_is_trusted(metadata.uid(), scope) { + return Err(discovery_error( + "provider endpoint directory ownership is outside the trust domain", + )); + } + checked_any = true; + } + if !checked_any { + return Err(discovery_error("provider endpoint has no parent directory")); + } + Ok(()) +} + +#[cfg(windows)] +fn check_file_security(path: &Path, _scope: RegistrationScope, _executable: bool) -> Result<()> { + if !path.is_file() { + return Err(discovery_error("provider endpoint is not a regular file")); + } + let system_scope = _scope == RegistrationScope::System; + match crate::windows_security::path_acl_is_trusted( + path, + crate::windows_security::AclObjectKind::File, + system_scope, + ) { + Ok(true) => Ok(()), + Ok(false) => Err(discovery_error( + "provider endpoint ACL is outside the trust domain", + )), + Err(_) => Err(discovery_error( + "provider endpoint ACL could not be validated", + )), + } +} + +#[cfg(windows)] +fn check_parent_security(path: &Path, _scope: RegistrationScope) -> Result<()> { + check_windows_parent_security_with( + path, + _scope == RegistrationScope::System, + |ancestor, system_scope| { + crate::windows_security::path_acl_is_trusted( + ancestor, + crate::windows_security::AclObjectKind::Directory, + system_scope, + ) + }, + ) +} + +#[cfg(windows)] +fn check_windows_parent_security_with( + path: &Path, + system_scope: bool, + mut acl_is_trusted: F, +) -> Result<()> +where + F: FnMut(&Path, bool) -> std::io::Result, +{ + // A trusted endpoint and immediate directory are not enough: write or + // FILE_DELETE_CHILD access on any higher directory lets an attacker + // replace the protected subtree after validation. Walk the canonical path + // so junctions and other reparse points are checked where they resolve. + let resolved = std::fs::canonicalize(path).map_err(discovery_io)?; + let mut checked_any = false; + for ancestor in resolved.ancestors().skip(1) { + if !ancestor.is_dir() { + return Err(discovery_error( + "provider endpoint path component is not a directory", + )); + } + match acl_is_trusted(ancestor, system_scope) { + Ok(true) => {} + Ok(false) => { + return Err(discovery_error( + "provider endpoint directory ACL is outside the trust domain", + )); + } + Err(_) => { + return Err(discovery_error( + "provider endpoint directory ACL could not be validated", + )); + } + } + checked_any = true; + } + if checked_any { + Ok(()) + } else { + Err(discovery_error("provider endpoint has no parent directory")) + } +} + +#[cfg(unix)] +fn same_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + left.len() == right.len() + && left.file_type() == right.file_type() + && left.modified().ok() == right.modified().ok() +} + +#[cfg(unix)] +fn is_privileged_process() -> bool { + effective_uid() == 0 || effective_uid() != real_uid() || effective_gid() != real_gid() +} + +#[cfg(windows)] +fn is_privileged_process() -> bool { + // The default policy cannot safely distinguish every Windows service and + // elevated-token shape without broadening the platform dependency surface. + // Fail closed for PATH; an embedder may opt in through an injected policy. + true +} + +#[cfg(unix)] +fn effective_uid() -> u32 { + unsafe extern "C" { + fn geteuid() -> u32; + } + // SAFETY: `geteuid` has no arguments and no memory-safety preconditions. + unsafe { geteuid() } +} + +#[cfg(unix)] +fn real_uid() -> u32 { + unsafe extern "C" { + fn getuid() -> u32; + } + // SAFETY: `getuid` has no arguments and no memory-safety preconditions. + unsafe { getuid() } +} + +#[cfg(unix)] +fn effective_gid() -> u32 { + unsafe extern "C" { + fn getegid() -> u32; + } + // SAFETY: `getegid` has no arguments and no memory-safety preconditions. + unsafe { getegid() } +} + +#[cfg(unix)] +fn real_gid() -> u32 { + unsafe extern "C" { + fn getgid() -> u32; + } + // SAFETY: `getgid` has no arguments and no memory-safety preconditions. + unsafe { getgid() } +} + +fn platform_directories() -> (Option, Option) { + #[cfg(target_os = "linux")] + { + let user = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .map(|base| base.join("secretspec/providers.d")); + (user, Some(PathBuf::from("/etc/secretspec/providers.d"))) + } + #[cfg(target_os = "macos")] + { + let user = std::env::var_os("HOME").map(|home| { + PathBuf::from(home).join("Library/Application Support/SecretSpec/providers.d") + }); + ( + user, + Some(PathBuf::from( + "/Library/Application Support/SecretSpec/providers.d", + )), + ) + } + #[cfg(windows)] + { + let user = std::env::var_os("APPDATA") + .map(PathBuf::from) + .map(|base| base.join("SecretSpec/providers.d")); + let system = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .map(|base| base.join("SecretSpec/providers.d")); + (user, system) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + { + (None, None) + } +} + +fn discovery_io(_: std::io::Error) -> SecretSpecError { + discovery_error("provider discovery I/O failed") +} + +fn discovery_error(message: &str) -> SecretSpecError { + SecretSpecError::ProviderOperationFailed(message.to_string()) +} + +/// Resolves credentials requested by one already-discovered external provider. +/// +/// The selected scheme is supplied separately from the endpoint-controlled +/// request and MUST be part of the backing-store namespace. Implementations +/// return `None` for an ordinary miss and must never place a value in an error. +pub trait ProviderCredentialBroker: Send + Sync + 'static { + fn get( + &self, + scheme: &str, + request: &ProviderCredentialRequest, + ) -> Result>; +} + +#[derive(Default)] +pub(crate) struct KeyringCredentialBroker; + +impl ProviderCredentialBroker for KeyringCredentialBroker { + fn get( + &self, + scheme: &str, + request: &ProviderCredentialRequest, + ) -> Result> { + #[cfg(feature = "keyring")] + { + use crate::provider::keyring::{KeyringConfig, KeyringProvider}; + + let provider = KeyringProvider::new(KeyringConfig::default()); + let address = brokered_credential_address(scheme, &request.scope, &request.name); + match provider.get(Address::Native(&address)) { + // An optional broker lookup must not prevent the endpoint from + // using its native environment, agent, or workload identity merely + // because this machine has no usable keyring service. + Err(_) if !request.required => Ok(None), + result => result, + } + } + #[cfg(not(feature = "keyring"))] + { + let _ = (scheme, request); + Ok(None) + } + } +} + +/// Stable, provider-private keyring address for a dynamically requested +/// credential. Hashing the endpoint-controlled scope prevents separators or +/// platform keyring limits from collapsing two namespaces; the scheme and +/// semantic name remain visible for diagnostics and keyring UIs. +pub(crate) fn brokered_credential_address(scheme: &str, scope: &str, name: &str) -> NativeAddress { + let digest = Sha256::digest(scope.as_bytes()); + let mut scope_hash = String::with_capacity(digest.len() * 2); + use std::fmt::Write as _; + for byte in digest { + let _ = write!(scope_hash, "{byte:02x}"); + } + NativeAddress { + item: format!("secretspec/provider-credentials/{scheme}/{scope_hash}/{name}"), + ..NativeAddress::default() + } +} + +#[cfg(any(feature = "cli", test))] +pub(crate) fn store_brokered_credential( + scheme: &str, + scope: &str, + name: &str, + value: &SecretString, +) -> Result { + #[cfg(feature = "keyring")] + { + use crate::provider::keyring::{KeyringConfig, KeyringProvider}; + + let provider = KeyringProvider::new(KeyringConfig::default()); + let address = brokered_credential_address(scheme, scope, name); + provider.set(Address::Native(&address), value)?; + Ok(format!("keyring at {}", address.render())) + } + #[cfg(not(feature = "keyring"))] + { + let _ = (scheme, scope, name, value); + Err(SecretSpecError::ProviderOperationFailed( + "this SecretSpec build has no system-keyring support".into(), + )) + } +} + +struct ExternalCredentialResponder { + scheme: String, + explicit: ProviderCredentials, + broker: Arc, + names: Mutex>, + broker_error: Arc>>, +} + +#[async_trait::async_trait] +impl CredentialResponder for ExternalCredentialResponder { + async fn credential( + &self, + request: ProviderCredentialRequest, + ) -> std::result::Result { + // Bound the authority surface independently from frame size. Repeated + // requests for the same credential remain valid for token refresh. + let identity = (request.scope.clone(), request.name.clone()); + { + let mut names = self + .names + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !names.contains(&identity) && names.len() >= 64 { + return Err(RpcError::new(RpcErrorKind::InvalidParams)); + } + names.insert(identity); + } + let value = match self.explicit.get(&request.name).cloned() { + Some(value) => Some(value), + None => { + let broker = self.broker.clone(); + let scheme = self.scheme.clone(); + let result = tokio::task::spawn_blocking(move || broker.get(&scheme, &request)) + .await + .map_err(|_| RpcError::new(RpcErrorKind::OperationFailed))?; + match result { + Ok(value) => value, + Err(error) => { + *self + .broker_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some(error.to_string()); + return Err(RpcError::new(RpcErrorKind::OperationFailed)); + } + } + } + }; + Ok(match value { + Some(value) if !value.expose_secret().is_empty() => CredentialResult::Found { + value: value.expose_secret().to_string(), + }, + _ => CredentialResult::Missing, + }) + } +} + +struct ExternalState { + project: Option, + profile: Option, + base_dir: Option, + credentials: ProviderCredentials, + credential_broker: Arc, + credential_error: Arc>>, + reason: Option, + requested_authorization_duration: Option, + /// Latched rejection from the last `with_base_dir`, cleared when a later + /// call supplies an acceptable value. + base_dir_error: Option, + session: Option>, +} + +impl ExternalState { + /// The configuration rejection that must block session startup, if any. + fn configuration_error(&self) -> Option<&str> { + self.base_dir_error.as_deref() + } +} + +/// A core provider backed by one `secretspec.provider/1` endpoint. +/// +/// Endpoint startup is lazy so project/profile context, `with_base_dir`, the +/// credential broker, and the initial `set_reason` are applied to immutable +/// initialization state first. +pub struct ExternalProvider { + endpoint: ProviderEndpoint, + scheme: String, + configured_uri: String, + state: Mutex, + metadata: OnceLock, +} + +impl ExternalProvider { + /// Constructs a provider from an explicit endpoint and configured URI. + /// The executable is canonicalized and checked before it is retained. + pub fn new(endpoint: ProviderEndpoint, uri: &str) -> Result { + let endpoint = validate_endpoint( + endpoint.clone(), + &endpoint.scheme, + RegistrationScope::Explicit, + &PlatformEndpointSecurity, + )?; + let url = + url::Url::parse(uri).map_err(|_| discovery_error("invalid external provider URI"))?; + let url = ProviderUrl::new(url); + if url.scheme() != endpoint.scheme { + return Err(discovery_error( + "external provider URI scheme does not match endpoint", + )); + } + super::reject_uri_credential(&url)?; + Ok(Self::from_url(endpoint, &url)) + } + + pub(crate) fn from_url(endpoint: ProviderEndpoint, url: &ProviderUrl) -> Self { + Self { + scheme: endpoint.scheme.clone(), + endpoint, + configured_uri: url.to_string(), + state: Mutex::new(ExternalState { + project: None, + profile: None, + base_dir: None, + credentials: ProviderCredentials::new(), + credential_broker: Arc::new(KeyringCredentialBroker), + credential_error: Arc::new(Mutex::new(None)), + reason: None, + requested_authorization_duration: None, + base_dir_error: None, + session: None, + }), + metadata: OnceLock::new(), + } + } + + fn state(&self) -> MutexGuard<'_, ExternalState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Replaces the default system-keyring broker before the endpoint starts. + /// Embedders can use this to enforce their own credential policy (0.20+). + pub fn with_credential_broker(&mut self, broker: Arc) { + let session = { + let mut state = self.state(); + state.credential_broker = broker; + state + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + #[cfg(any(feature = "cli", test))] + pub(crate) fn initialize(&self) -> Result<()> { + self.ensure_session().map(|_| ()) + } + + fn ensure_session(&self) -> Result> { + let mut state = self.state(); + if let Some(message) = state.configuration_error() { + return Err(discovery_error(message)); + } + if let Some(session) = &state.session { + if !session.is_closed() { + return Ok(session.clone()); + } + if let Some(stale) = state.session.take() { + close_live_session(stale); + } + } + let application = InitializeApplication { + scheme: self.scheme.clone(), + uri: self.configured_uri.clone(), + context: ApplicationContext { + project: state.project.clone(), + profile: state.profile.clone(), + base_dir: state + .base_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + reason: state.reason.clone(), + requested_authorization_duration_ms: state + .requested_authorization_duration + .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)), + }, + }; + let responder = Arc::new(ExternalCredentialResponder { + scheme: self.scheme.clone(), + explicit: state.credentials.clone(), + broker: state.credential_broker.clone(), + names: Mutex::new(HashSet::new()), + broker_error: state.credential_error.clone(), + }); + let launch = LaunchOptions { + executable: self.endpoint.executable.clone(), + arguments: self.endpoint.arguments.iter().map(OsString::from).collect(), + environment: Environment::Inherit(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 64 * 1024, + }; + let launched = super::block_on(ProviderSession::launch_with_credential_broker( + launch, + Product { + name: "secretspec".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + Limits { + max_frame_bytes: secretspec_ipc::ABSOLUTE_MAX_FRAME_BYTES, + max_in_flight: 16, + }, + application, + deadline_unix_ms_after(STARTUP_TIMEOUT), + Some(responder.clone()), + )); + let session = match launched { + Ok(session) => session, + Err(error) => { + if let Some(message) = state + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + return Err(SecretSpecError::ProviderOperationFailed(message)); + } + return Err(ipc_error(error)); + } + }; + // An endpoint may deliberately catch a failed optional lookup and use + // native authentication instead. Do not let that handled failure leak + // into a later operation on the healthy session. + state + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(existing) = self.metadata.get() { + if existing != session.metadata() { + let _ = + super::block_on(session.close(deadline_unix_ms_after(Duration::from_secs(1)))); + return Err(discovery_error("provider metadata changed after reconnect")); + } + } else { + let _ = self.metadata.set(session.metadata().clone()); + } + let session = Arc::new(session); + state.session = Some(session.clone()); + Ok(session) + } + + /// Endpoint-reported metadata, starting the session if it has not run yet. + /// + /// Only for accessors that are allowed to contact the store. The identity + /// accessors deliberately do not use this: `Secrets` reconstructs canonical + /// URIs and storage identities from a freshly built, *uncredentialed* + /// provider while planning, and that path is documented as touching no + /// store. Launching an endpoint there would both break that contract and + /// derive an identity from a session that never received its credentials. + fn endpoint_metadata(&self) -> Option<&wire::Metadata> { + let _ = self.ensure_session(); + self.metadata.get() + } + + fn require(&self, method: &str) -> Result> { + let session = self.ensure_session()?; + if session.supports(method) { + Ok(session) + } else { + Err(discovery_error(&format!( + "external provider '{}' does not support {method}", + self.scheme + ))) + } + } + + fn call(&self, params: &M::Params) -> Result + where + M: wire::method::Method, + { + let session = self.require(M::NAME)?; + let result = super::block_on( + session.execute::(params, deadline_unix_ms_after(OPERATION_TIMEOUT)), + ); + if result.is_err() && session.is_closed() { + let stale = { + let mut state = self.state(); + if state + .session + .as_ref() + .is_some_and(|active| Arc::ptr_eq(active, &session)) + { + state.session.take() + } else { + None + } + }; + if let Some(stale) = stale { + close_live_session(stale); + } + } + match result { + Ok(value) => { + self.state() + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + Ok(value) + } + Err(error) => { + if let Some(message) = self + .state() + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + Err(SecretSpecError::ProviderOperationFailed(message)) + } else { + Err(ipc_error(error)) + } + } + } + } + + fn resolve_remote(&self, address: Address<'_>) -> Result { + let result = self.call::(&AddressParams { + address: to_wire_address(address), + })?; + from_wire_coordinates(result.coordinates) + } + + /// Optional protocol presence check, without exposing a value. + pub fn exists(&self, address: Address<'_>) -> Result { + // Probe the capability set once. Re-acquiring the session per branch + // would let the capability check and the call it guards observe two + // different endpoints if the session were replaced in between. + let session = self.ensure_session()?; + if session.supports(wire::method::EXISTS) { + let result = self.call::(&AddressParams { + address: to_wire_address(address), + })?; + Ok(result.exists) + } else if session.supports(wire::method::GET) { + Ok(self.get(address)?.is_some()) + } else { + Err(discovery_error( + "external provider cannot perform a presence check", + )) + } + } + + /// Optional bounded protocol cache clear. This is deliberately not + /// emulated through reflection. + pub fn clear(&self, scope: wire::ClearScope) -> Result { + let result = self.call::(&wire::ClearParams { scope })?; + Ok(result.cleared) + } +} + +impl Provider for ExternalProvider { + fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { + self.resolve_remote(Address::Convention { + project, + profile, + key, + }) + } + + fn supports_coord(&self, name: &str) -> bool { + self.ensure_session() + .ok() + .and_then(|_| self.metadata.get()) + .is_some_and(|metadata| { + metadata + .supported_coordinates + .iter() + .any(|coordinate| coordinate.as_str() == name) + }) + } + + fn supports_read(&self) -> bool { + // Initialization errors must surface from the attempted operation, not + // be misreported by core workflows as a healthy write-only endpoint. + // Until negotiation succeeds, preserve the trait's readable default. + self.ensure_session().map_or(true, |session| { + session.supports(wire::method::GET) || session.supports(wire::method::GET_MANY) + }) + } + + fn exists(&self, addr: Address<'_>) -> Result { + ExternalProvider::exists(self, addr) + } + + fn supports_delete(&self) -> bool { + self.ensure_session() + .is_ok_and(|session| session.supports(wire::method::DELETE)) + } + + fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { + self.resolve_remote(addr).map(std::borrow::Cow::Owned) + } + + fn entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.resolve_remote(addr).map(std::borrow::Cow::Owned) + } + + fn get(&self, addr: Address<'_>) -> Result> { + self.get_with_metadata(addr) + .map(|value| value.map(|value| value.value)) + } + + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + let result = self.call::(&AddressParams { + address: to_wire_address(addr), + })?; + Ok(match result { + GetResult::Found { + value, + expires_at_unix_ms, + } => Some(ProviderValue::new( + SecretString::from(value), + expires_at_unix_ms, + )), + GetResult::Missing => None, + }) + } + + fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + self.get_many_with_metadata(requests).map(|values| { + values + .into_iter() + .map(|(name, value)| (name, value.value)) + .collect() + }) + } + + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + if !self.ensure_session()?.supports(wire::method::GET_MANY) { + return get_each_with(requests, |address| self.get_with_metadata(address)); + } + let params = GetManyParams { + requests: requests + .iter() + .map(|(name, address)| NamedRequest { + name: (*name).to_string(), + address: to_wire_address(*address), + }) + .collect(), + }; + let result = self.call::(¶ms)?; + if result.results.len() != requests.len() + || result + .results + .iter() + .zip(requests) + .any(|(actual, (expected, _))| actual.name != *expected) + { + return Err(discovery_error( + "provider batch response did not preserve request names", + )); + } + Ok(result + .results + .into_iter() + .filter_map(|item| match item.outcome { + GetResult::Found { + value, + expires_at_unix_ms, + } => Some(( + item.name, + ProviderValue::new(SecretString::from(value), expires_at_unix_ms), + )), + GetResult::Missing => None, + }) + .collect()) + } + + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + if self.ensure_session()?.supports(wire::method::EXISTS) { + return exists_each(self, requests); + } + self.get_many(requests) + .map(|values| values.into_keys().collect()) + } + + fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { + self.check_writable(addr)?; + let result = self.call::(&SetParams { + address: to_wire_address(addr), + value: value.expose_secret().to_string(), + })?; + if result.stored { + Ok(()) + } else { + Err(discovery_error("provider did not confirm the write")) + } + } + + fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretString, + max_age: Duration, + ) -> Result<()> { + if !self.ensure_session()?.supports(wire::method::SET_EXPIRING) { + return self.set(addr, value); + } + self.check_writable(addr)?; + let ttl_ms = max_age.as_millis().try_into().unwrap_or(u64::MAX); + if ttl_ms == 0 { + return Err(discovery_error("external provider expiry must be positive")); + } + let result = self.call::(&SetExpiringParams { + address: to_wire_address(addr), + value: value.expose_secret().to_string(), + ttl_ms, + })?; + if result.stored { + Ok(()) + } else { + Err(discovery_error( + "provider did not confirm the expiring write", + )) + } + } + + fn delete(&self, addr: Address<'_>) -> Result { + self.check_deletable(addr)?; + let result = self.call::(&AddressParams { + address: to_wire_address(addr), + })?; + Ok(result.deleted) + } + + fn check_writable(&self, addr: Address<'_>) -> Result<()> { + let session = self.require(wire::method::SET)?; + if !session.supports(wire::method::CHECK_WRITABLE) { + return Ok(()); + } + self.call::(&AddressParams { + address: to_wire_address(addr), + }) + .map(|_| ()) + } + + fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + let session = self.require(wire::method::DELETE)?; + if !session.supports(wire::method::CHECK_DELETABLE) { + return Ok(()); + } + self.call::(&AddressParams { + address: to_wire_address(addr), + }) + .map(|_| ()) + } + + fn generated_value_persistence(&self) -> ProducedValuePersistence { + self.ensure_session() + .ok() + .and_then(|_| self.metadata.get()) + .map_or(ProducedValuePersistence::Persist, |metadata| { + map_persistence(metadata.generated_value_persistence) + }) + } + + fn prompted_value_persistence(&self) -> ProducedValuePersistence { + self.ensure_session() + .ok() + .and_then(|_| self.metadata.get()) + .map_or(ProducedValuePersistence::Persist, |metadata| { + map_persistence(metadata.prompted_value_persistence) + }) + } + + fn describe_write_target(&self, addr: Address<'_>) -> Result { + if self + .ensure_session()? + .supports(wire::method::DESCRIBE_WRITE_TARGET) + { + let result = self.call::(&AddressParams { + address: to_wire_address(addr), + })?; + Ok(result.description) + } else { + Ok(self.resolve_remote(addr)?.render()) + } + } + + fn auth_scope_key(&self) -> Option { + Some(format!( + "{}:{}:{}", + self.scheme, + self.endpoint.executable.display(), + self.configured_uri + )) + } + + fn name(&self) -> &str { + &self.scheme + } + + // The three identity accessors below never start a session, because route + // planning derives canonical URIs and storage identities from a freshly + // built, uncredentialed provider and is documented as touching no store. + // They report the endpoint's own spelling once a session exists for another + // reason, and the configured URI until then. + + fn uri(&self) -> String { + self.metadata + .get() + .map(|metadata| metadata.display_uri.clone()) + .unwrap_or_else(|| self.configured_uri.clone()) + } + + fn storage_identity(&self) -> String { + self.metadata + .get() + .map(|metadata| metadata.storage_identity.clone()) + .unwrap_or_else(|| self.configured_uri.clone()) + } + + fn entry_container_identity(&self) -> String { + self.metadata + .get() + .map(|metadata| metadata.entry_container_identity.clone()) + .unwrap_or_else(|| self.storage_identity()) + } + + fn physical_store_path(&self) -> Option<&Path> { + self.endpoint_metadata() + .and_then(|metadata| metadata.physical_store_path.as_deref()) + .map(Path::new) + } + + fn set_reason(&self, reason: Option) { + let session = { + let mut state = self.state(); + if state.reason == reason { + return; + } + state.reason = reason; + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + fn set_requested_authorization_duration(&self, duration: Option) { + let session = { + let mut state = self.state(); + if state.requested_authorization_duration == duration { + return; + } + state.requested_authorization_duration = duration; + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + fn set_project(&self, project: &str) { + let session = { + let mut state = self.state(); + if state.project.as_deref() == Some(project) { + return; + } + state.project = Some(project.to_string()); + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + fn set_profile(&self, profile: &str) { + let session = { + let mut state = self.state(); + if state.profile.as_deref() == Some(profile) { + return; + } + state.profile = Some(profile.to_string()); + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + fn with_base_dir(&mut self, base_dir: &Path) { + let mut state = self.state(); + // These hooks cannot report an error, so a rejected value is latched + // until the next call to the same hook. Each setter therefore owns + // exactly one latch and clears it on success, so correcting a value + // recovers the provider instead of poisoning it permanently. + if base_dir.is_absolute() { + state.base_dir = Some(base_dir.to_path_buf()); + state.base_dir_error = None; + } else { + state.base_dir_error = + Some("external provider base directory is not absolute".to_string()); + } + } + + fn with_credentials(&mut self, credentials: ProviderCredentials) { + let session = { + let mut state = self.state(); + state.credentials = credentials; + state + .credential_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + state.session.take() + }; + if let Some(session) = session { + close_live_session(session); + } + } + + fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { + let result = self.call::(&ReflectParams { + project: context.project.to_string(), + profile: context.profile.to_string(), + })?; + if result.schema_version != 1 { + return Err(discovery_error( + "provider reflection schema version is unsupported", + )); + } + result + .declarations + .into_iter() + .map(|(name, declaration)| { + let reference = from_wire_coordinates(declaration.reference)?; + let secret = if declaration.required { + Secret::required(declaration.description) + } else { + Secret::optional(declaration.description) + } + .reference(reference); + Ok((name, secret)) + }) + .collect() + } +} + +impl Drop for ExternalProvider { + fn drop(&mut self) { + if let Some(session) = self + .state + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .session + .take() + { + close_live_session(session); + } + } +} + +fn close_live_session(session: Arc) { + let _ = super::block_on(session.close(deadline_unix_ms_after(Duration::from_secs(2)))); +} + +fn to_wire_address(address: Address<'_>) -> wire::Address { + match address { + Address::Convention { + project, + profile, + key, + } => wire::Address::Convention { + project: project.to_string(), + profile: profile.to_string(), + key: key.to_string(), + }, + Address::Native(address) => wire::Address::Native { + coordinates: wire::Coordinates { + item: address.item.clone(), + field: address.field.clone(), + vault: address.vault.clone(), + section: address.section.clone(), + version: address.version.clone(), + }, + }, + } +} + +fn from_wire_coordinates(coordinates: wire::Coordinates) -> Result { + coordinates.validate().map_err(ipc_error)?; + Ok(NativeAddress { + item: coordinates.item, + field: coordinates.field, + vault: coordinates.vault, + section: coordinates.section, + version: coordinates.version, + }) +} + +fn map_persistence(value: Persistence) -> ProducedValuePersistence { + match value { + Persistence::Persist => ProducedValuePersistence::Persist, + Persistence::Ephemeral => ProducedValuePersistence::Ephemeral, + } +} + +fn ipc_error(error: secretspec_ipc::Error) -> SecretSpecError { + match error { + secretspec_ipc::Error::Remote(error) => SecretSpecError::ProviderProtocol { + kind: error.data.kind, + interaction: error.data.interaction, + }, + error => match error.rpc_kind() { + Some(kind) => SecretSpecError::ProviderProtocol { + kind, + interaction: None, + }, + None => SecretSpecError::ProviderOperationFailed(error.stable_message().to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(directory: &Path, name: &str, argument: &str) -> ProviderEndpoint { + let executable = directory.join(name); + std::fs::write(&executable, name).unwrap(); + ProviderEndpoint { + scheme: "example".into(), + executable, + arguments: vec![argument.into()], + } + } + + fn write_registration(directory: &Path, endpoint: &ProviderEndpoint) { + std::fs::create_dir_all(directory).unwrap(); + std::fs::write( + directory.join("example.secretspec.json"), + serde_json::to_vec(&serde_json::json!({ + "executable": endpoint.executable, + })) + .unwrap(), + ) + .unwrap(); + } + + fn write_path_endpoint(directory: &Path) -> PathBuf { + std::fs::create_dir_all(directory).unwrap(); + let name = if cfg!(windows) { + "secretspec-provider-example.exe" + } else { + "secretspec-provider-example" + }; + let executable = directory.join(name); + std::fs::write(&executable, "path").unwrap(); + executable + } + + struct AllowAll; + + impl EndpointSecurity for AllowAll { + fn check_registration(&self, _: &Path, _: RegistrationScope) -> Result<()> { + Ok(()) + } + fn check_executable(&self, _: &Path, _: RegistrationScope) -> Result<()> { + Ok(()) + } + fn privileged(&self) -> bool { + false + } + } + + struct DenyAll; + + impl EndpointSecurity for DenyAll { + fn check_registration(&self, _: &Path, _: RegistrationScope) -> Result<()> { + Err(discovery_error("rejected by test policy")) + } + fn check_executable(&self, _: &Path, _: RegistrationScope) -> Result<()> { + Err(discovery_error("rejected by test policy")) + } + fn privileged(&self) -> bool { + true + } + } + + #[test] + fn discovery_precedence_and_extensible_registration() { + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("endpoint"); + std::fs::write(&executable, "endpoint").unwrap(); + let registration_dir = directory.path().join("providers.d"); + std::fs::create_dir(®istration_dir).unwrap(); + std::fs::write( + registration_dir.join("example.secretspec.json"), + serde_json::json!({ + "executable": executable, + "future_field": true + }) + .to_string(), + ) + .unwrap(); + let discovery = ProviderDiscovery { + explicit: BTreeMap::new(), + user_directory: Some(registration_dir.clone()), + system_directory: None, + allow_path: false, + }; + let endpoint = discovery + .resolve_with_security("example", &AllowAll) + .unwrap() + .unwrap(); + assert_eq!(endpoint.arguments, ["provider"]); + + std::fs::write( + registration_dir.join("bad.secretspec.json"), + serde_json::json!({ + "executable": "relative/provider" + }) + .to_string(), + ) + .unwrap(); + assert!(discovery.resolve_with_security("bad", &AllowAll).is_err()); + } + + #[test] + fn explicit_endpoint_precedes_user_system_and_path() { + let root = tempfile::tempdir().unwrap(); + let explicit = endpoint(root.path(), "explicit", "explicit"); + let user = endpoint(root.path(), "user", "user"); + let system = endpoint(root.path(), "system", "system"); + let user_directory = root.path().join("user.d"); + let system_directory = root.path().join("system.d"); + let path_directory = root.path().join("bin"); + write_registration(&user_directory, &user); + write_registration(&system_directory, &system); + write_path_endpoint(&path_directory); + let search_path = std::env::join_paths([&path_directory]).unwrap(); + let discovery = ProviderDiscovery { + explicit: BTreeMap::from([("example".into(), explicit)]), + user_directory: Some(user_directory), + system_directory: Some(system_directory), + allow_path: true, + }; + + let selected = discovery + .resolve_with_security_and_search_path( + "example", + &AllowAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.arguments, ["explicit"]); + } + + #[test] + fn user_registration_precedes_system_and_path() { + let root = tempfile::tempdir().unwrap(); + let user = endpoint(root.path(), "user", "user"); + let system = endpoint(root.path(), "system", "system"); + let user_directory = root.path().join("user.d"); + let system_directory = root.path().join("system.d"); + let path_directory = root.path().join("bin"); + write_registration(&user_directory, &user); + write_registration(&system_directory, &system); + write_path_endpoint(&path_directory); + let search_path = std::env::join_paths([&path_directory]).unwrap(); + let discovery = ProviderDiscovery { + explicit: BTreeMap::new(), + user_directory: Some(user_directory), + system_directory: Some(system_directory), + allow_path: true, + }; + + let selected = discovery + .resolve_with_security_and_search_path( + "example", + &AllowAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.arguments, ["provider"]); + } + + #[test] + fn system_registration_precedes_path() { + let root = tempfile::tempdir().unwrap(); + let system = endpoint(root.path(), "system", "system"); + let system_directory = root.path().join("system.d"); + let path_directory = root.path().join("bin"); + write_registration(&system_directory, &system); + write_path_endpoint(&path_directory); + let search_path = std::env::join_paths([&path_directory]).unwrap(); + let discovery = ProviderDiscovery { + explicit: BTreeMap::new(), + user_directory: None, + system_directory: Some(system_directory), + allow_path: true, + }; + + let selected = discovery + .resolve_with_security_and_search_path( + "example", + &AllowAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.arguments, ["provider"]); + } + + #[test] + fn path_discovery_requires_opt_in_and_is_disabled_when_privileged() { + let root = tempfile::tempdir().unwrap(); + let path_directory = root.path().join("bin"); + let executable = write_path_endpoint(&path_directory); + let search_path = std::env::join_paths([&path_directory]).unwrap(); + let mut discovery = ProviderDiscovery::default(); + + assert!( + discovery + .resolve_with_security_and_search_path( + "example", + &AllowAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .is_none() + ); + discovery.allow_path = true; + assert!( + discovery + .resolve_with_security_and_search_path( + "example", + &DenyAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .is_none() + ); + let selected = discovery + .resolve_with_security_and_search_path( + "example", + &AllowAll, + Some(search_path.as_os_str()), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.executable, executable.canonicalize().unwrap()); + } + + #[test] + fn registration_change_does_not_mutate_a_resolved_endpoint() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("user.d"); + let first_endpoint = endpoint(root.path(), "first", "first"); + let second_endpoint = endpoint(root.path(), "second", "second"); + write_registration(&directory, &first_endpoint); + let discovery = ProviderDiscovery { + user_directory: Some(directory.clone()), + ..ProviderDiscovery::default() + }; + let first = discovery + .resolve_with_security_and_search_path("example", &AllowAll, None) + .unwrap() + .unwrap(); + + write_registration(&directory, &second_endpoint); + let second = discovery + .resolve_with_security_and_search_path("example", &AllowAll, None) + .unwrap() + .unwrap(); + + assert_eq!(first.arguments, ["provider"]); + assert_eq!(second.arguments, ["provider"]); + assert_ne!(first.executable, second.executable); + } + + #[test] + fn dynamic_trait_surface_is_object_safe() { + fn accepts(_: &dyn Provider) {} + fn accepts_arc(_: Arc) {} + + struct DynamicName(String); + impl Provider for DynamicName { + fn convention_address(&self, _: &str, _: &str, key: &str) -> Result { + Ok(NativeAddress { + item: key.into(), + ..NativeAddress::default() + }) + } + fn get(&self, _: Address<'_>) -> Result> { + Ok(None) + } + fn set(&self, _: Address<'_>, _: &SecretString) -> Result<()> { + Ok(()) + } + fn name(&self) -> &str { + &self.0 + } + fn uri(&self) -> String { + format!("{}://", self.0) + } + } + + let direct = Arc::new(DynamicName("dynamic".into())); + accepts(direct.as_ref()); + accepts_arc(direct); + let boxed: Box = Box::new(DynamicName("boxed".into())); + accepts(boxed.as_ref()); + } + + #[test] + fn injected_security_policy_can_reject_an_explicit_endpoint() { + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("endpoint"); + std::fs::write(&executable, "endpoint").unwrap(); + let discovery = ProviderDiscovery { + explicit: BTreeMap::from([( + "example".into(), + ProviderEndpoint { + scheme: "example".into(), + executable, + arguments: Vec::new(), + }, + )]), + ..ProviderDiscovery::default() + }; + assert!( + discovery + .resolve_with_security("example", &DenyAll) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn platform_policy_accepts_owner_only_files_and_rejects_writable_registration() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("endpoint"); + std::fs::write(&executable, "endpoint").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let registration_dir = directory.path().join("providers.d"); + std::fs::create_dir(®istration_dir).unwrap(); + std::fs::set_permissions(®istration_dir, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let registration = registration_dir.join("example.secretspec.json"); + std::fs::write( + ®istration, + serde_json::json!({ + "executable": executable + }) + .to_string(), + ) + .unwrap(); + std::fs::set_permissions(®istration, std::fs::Permissions::from_mode(0o600)).unwrap(); + let discovery = ProviderDiscovery { + user_directory: Some(registration_dir), + ..ProviderDiscovery::default() + }; + assert!(discovery.resolve("example").unwrap().is_some()); + + std::fs::set_permissions(®istration, std::fs::Permissions::from_mode(0o622)).unwrap(); + assert!(discovery.resolve("example").is_err()); + } + + /// Builds a discoverable registration whose endpoint lives at `executable`. + #[cfg(unix)] + fn registration_for(directory: &Path, executable: &Path) -> ProviderDiscovery { + use std::os::unix::fs::PermissionsExt; + let registration_dir = directory.join("providers.d"); + std::fs::create_dir_all(®istration_dir).unwrap(); + std::fs::set_permissions(®istration_dir, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let registration = registration_dir.join("example.secretspec.json"); + std::fs::write( + ®istration, + serde_json::json!({ + "executable": executable + }) + .to_string(), + ) + .unwrap(); + std::fs::set_permissions(®istration, std::fs::Permissions::from_mode(0o600)).unwrap(); + ProviderDiscovery { + user_directory: Some(registration_dir), + ..ProviderDiscovery::default() + } + } + + #[cfg(unix)] + fn owner_only_executable(at: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::fs::write(at, "endpoint").unwrap(); + std::fs::set_permissions(at, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + + /// A tight parent is not enough: anyone who can write to a directory above + /// it can replace the parent wholesale. + #[cfg(unix)] + #[test] + fn a_writable_ancestor_above_a_tight_parent_is_rejected() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let loose = directory.path().join("loose"); + std::fs::create_dir(&loose).unwrap(); + let bin = loose.join("bin"); + let executable = bin.join("endpoint"); + owner_only_executable(&executable); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let discovery = registration_for(directory.path(), &executable); + + std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(discovery.resolve("example").unwrap().is_some()); + + // Only the ancestor changes; the parent and the executable stay tight. + std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o777)).unwrap(); + let error = discovery.resolve("example").unwrap_err().to_string(); + assert!(error.contains("group- or world-writable"), "{error}"); + } + + /// A world-writable ancestor is safe when it is sticky, which is what makes + /// a build or temporary directory under /tmp usable. + #[cfg(unix)] + #[test] + fn a_sticky_world_writable_ancestor_is_accepted() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let sticky = directory.path().join("sticky"); + std::fs::create_dir(&sticky).unwrap(); + let bin = sticky.join("bin"); + let executable = bin.join("endpoint"); + owner_only_executable(&executable); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let discovery = registration_for(directory.path(), &executable); + std::fs::set_permissions(&sticky, std::fs::Permissions::from_mode(0o1777)).unwrap(); + assert!(discovery.resolve("example").unwrap().is_some()); + } + + /// A symlinked component is validated as the chain it resolves to, so a + /// redirect into a writable location is refused rather than followed. + #[cfg(unix)] + #[test] + fn a_symlinked_component_is_validated_through_to_its_target() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let real = directory.path().join("real"); + let executable = real.join("bin").join("endpoint"); + owner_only_executable(&executable); + std::fs::set_permissions( + executable.parent().unwrap(), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // The registration names a symlinked spelling of the same endpoint. + let linked = directory.path().join("linked"); + std::os::unix::fs::symlink(&real, &linked).unwrap(); + let discovery = registration_for(directory.path(), &linked.join("bin").join("endpoint")); + assert!(discovery.resolve("example").unwrap().is_some()); + + // Loosening the resolved target is caught through the link. + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o777)).unwrap(); + let error = discovery.resolve("example").unwrap_err().to_string(); + assert!(error.contains("group- or world-writable"), "{error}"); + } + + /// The Windows ACL walk must not stop after the immediate parent. Keeping + /// the ACL lookup injectable makes the traversal deterministic even when + /// the hosted runner's temporary and drive-root ACLs differ. + #[cfg(windows)] + #[test] + fn windows_rejects_an_untrusted_higher_executable_ancestor() { + let directory = tempfile::tempdir().unwrap(); + let higher = directory.path().join("higher"); + let parent = higher.join("bin"); + std::fs::create_dir_all(&parent).unwrap(); + let executable = parent.join("endpoint.exe"); + std::fs::write(&executable, "endpoint").unwrap(); + let higher = higher.canonicalize().unwrap(); + let parent = parent.canonicalize().unwrap(); + let mut checked = Vec::new(); + + let error = check_windows_parent_security_with(&executable, false, |ancestor, _| { + checked.push(ancestor.to_path_buf()); + Ok(ancestor != higher) + }) + .unwrap_err() + .to_string(); + + assert_eq!(checked.first(), Some(&parent)); + assert!( + checked.contains(&higher), + "higher ancestor was never checked" + ); + assert!(error.contains("outside the trust domain"), "{error}"); + } + + #[cfg(windows)] + #[test] + fn windows_accepts_a_trusted_chain_through_the_volume_root() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("bin"); + std::fs::create_dir(&parent).unwrap(); + let executable = parent.join("endpoint.exe"); + std::fs::write(&executable, "endpoint").unwrap(); + let resolved = executable.canonicalize().unwrap(); + let resolved_parent = resolved.parent().unwrap().to_path_buf(); + let volume_root = resolved.ancestors().last().unwrap().to_path_buf(); + let mut checked = Vec::new(); + + check_windows_parent_security_with(&executable, false, |ancestor, _| { + checked.push(ancestor.to_path_buf()); + // The directory ACL tests model the root's only untrusted effective + // right as FILE_ADD_SUBDIRECTORY, which is safe for existing paths. + Ok(true) + }) + .unwrap(); + + assert_eq!(checked.first(), Some(&resolved_parent)); + assert_eq!(checked.last(), Some(&volume_root)); + } + + #[cfg(windows)] + #[test] + fn windows_checks_the_resolved_target_of_a_directory_link() { + let directory = tempfile::tempdir().unwrap(); + let real = directory.path().join("real"); + let parent = real.join("bin"); + std::fs::create_dir_all(&parent).unwrap(); + let executable = parent.join("endpoint.exe"); + std::fs::write(&executable, "endpoint").unwrap(); + let linked = directory.path().join("linked"); + if let Err(error) = std::os::windows::fs::symlink_dir(&real, &linked) { + if error.kind() == std::io::ErrorKind::PermissionDenied { + return; + } + panic!("failed to create directory link: {error}"); + } + let linked_executable = linked.join("bin").join("endpoint.exe"); + let resolved_real = real.canonicalize().unwrap(); + let mut checked = Vec::new(); + + check_windows_parent_security_with(&linked_executable, false, |ancestor, _| { + checked.push(ancestor.to_path_buf()); + Ok(ancestor != resolved_real) + }) + .unwrap_err(); + + assert!( + checked.contains(&resolved_real), + "resolved target was never checked: {checked:?}" + ); + assert!( + !checked.contains(&linked), + "unresolved link spelling was checked: {checked:?}" + ); + } +} diff --git a/secretspec/src/provider/factory.rs b/secretspec/src/provider/factory.rs index 1f0fcc6ce..e09381a24 100644 --- a/secretspec/src/provider/factory.rs +++ b/secretspec/src/provider/factory.rs @@ -8,6 +8,7 @@ use super::{ use crate::{Result, SecretSpecError}; use percent_encoding::percent_encode; use std::convert::TryFrom; +use std::sync::Arc; use url::Url; impl TryFrom for Box { @@ -62,6 +63,37 @@ pub(crate) fn provider_from_spec( s: &str, credentials: ProviderCredentials, ) -> Result> { + let url = provider_url_from_spec(s)?; + provider_from_url(&url, credentials) +} + +/// Builds an external provider with a host-owned credential broker installed +/// before its endpoint can start. Runtime credential negotiation is exclusive +/// to external providers; a built-in scheme is rejected here rather than +/// silently ignoring the broker. +pub(crate) fn external_provider_from_spec( + s: &str, + broker: Arc, +) -> Result> { + let url = provider_url_from_spec(s)?; + reject_uri_credential(&url)?; + let scheme = url.scheme(); + if registration_for_scheme(scheme).is_some() { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "provider '{scheme}' does not use runtime credential negotiation" + ))); + } + let endpoint = super::external::discover(scheme)? + .ok_or_else(|| SecretSpecError::ProviderNotFound(scheme.to_string()))?; + let mut provider = super::external::ExternalProvider::from_url(endpoint, &url); + provider.with_credential_broker(broker); + Ok(Box::new(provider)) +} + +/// Parses and normalizes a provider spec without constructing or contacting +/// the provider. Shared with external-provider login, which must install its +/// credential broker before the endpoint is launched. +pub(crate) fn provider_url_from_spec(s: &str) -> Result { // Parse the scheme from the input string let (scheme, rest) = split_spec(s); @@ -140,7 +172,7 @@ pub(crate) fn provider_from_spec( )) })?; - provider_from_url(&ProviderUrl::new(proper_url), credentials) + Ok(ProviderUrl::new(proper_url)) } impl TryFrom<&Url> for Box { @@ -166,7 +198,7 @@ impl TryFrom<&Url> for Box { /// carries a credential rejects it itself. /// /// Since SecretSpec 0.19. -fn reject_uri_credential(url: &ProviderUrl) -> Result<()> { +pub(crate) fn reject_uri_credential(url: &ProviderUrl) -> Result<()> { if url.password().is_none() { return Ok(()); } @@ -210,17 +242,30 @@ fn reject_uri_credential(url: &ProviderUrl) -> Result<()> { pub(crate) fn provider_from_url( url: &ProviderUrl, credentials: ProviderCredentials, +) -> Result> { + provider_from_url_with_discovery(url, credentials, super::external::discover) +} + +pub(crate) fn provider_from_url_with_discovery( + url: &ProviderUrl, + credentials: ProviderCredentials, + discover: impl FnOnce(&str) -> Result>, ) -> Result> { reject_uri_credential(url)?; let scheme = url.scheme(); - let registration = registration_for_scheme(scheme) - .ok_or_else(|| SecretSpecError::ProviderNotFound(scheme.to_string()))?; - - let pwp = (registration.factory)(url, credentials)?; - if pwp.preflight.is_some() { - Ok(Box::new(PreflightGuard::new(pwp))) + if let Some(registration) = registration_for_scheme(scheme) { + let pwp = (registration.factory)(url, credentials)?; + if pwp.preflight.is_some() { + Ok(Box::new(PreflightGuard::new(pwp))) + } else { + Ok(pwp.provider) + } + } else if let Some(endpoint) = discover(scheme)? { + let mut provider = super::external::ExternalProvider::from_url(endpoint, url); + provider.with_credentials(credentials); + Ok(Box::new(provider)) } else { - Ok(pwp.provider) + Err(SecretSpecError::ProviderNotFound(scheme.to_string())) } } diff --git a/secretspec/src/provider/file.rs b/secretspec/src/provider/file.rs index 6a087b287..e03eb3b51 100644 --- a/secretspec/src/provider/file.rs +++ b/secretspec/src/provider/file.rs @@ -378,7 +378,7 @@ impl Provider for FileProvider { true } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/fly.rs b/secretspec/src/provider/fly.rs index b21707b15..0e78d8821 100644 --- a/secretspec/src/provider/fly.rs +++ b/secretspec/src/provider/fly.rs @@ -255,7 +255,7 @@ impl Provider for FlyProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/gcsm.rs b/secretspec/src/provider/gcsm.rs index fa5b76e1f..21e95900d 100644 --- a/secretspec/src/provider/gcsm.rs +++ b/secretspec/src/provider/gcsm.rs @@ -543,7 +543,7 @@ impl Provider for GcsmProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/gopass.rs b/secretspec/src/provider/gopass.rs index 1198780dc..821d8f715 100644 --- a/secretspec/src/provider/gopass.rs +++ b/secretspec/src/provider/gopass.rs @@ -274,7 +274,7 @@ impl Provider for GoPassProvider { true } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/infisical.rs b/secretspec/src/provider/infisical.rs index 50d06c0e2..7d42635ea 100644 --- a/secretspec/src/provider/infisical.rs +++ b/secretspec/src/provider/infisical.rs @@ -1114,7 +1114,7 @@ impl Provider for InfisicalProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/kdbx.rs b/secretspec/src/provider/kdbx.rs index 00638b302..4b2d4d80e 100644 --- a/secretspec/src/provider/kdbx.rs +++ b/secretspec/src/provider/kdbx.rs @@ -378,7 +378,7 @@ impl Provider for KdbxProvider { Ok(()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -615,7 +615,7 @@ mod tests { .unwrap(); assert_eq!(provider.name(), "kdbx"); assert_eq!( - crate::provider::credential_names_for_spec("kdbx:./vault.kdbx"), + crate::provider::credential_names_for_spec("kdbx:./vault.kdbx").unwrap(), &[PASSWORD_CREDENTIAL] ); } diff --git a/secretspec/src/provider/keeper.rs b/secretspec/src/provider/keeper.rs index 819cbbff3..1bbf67978 100644 --- a/secretspec/src/provider/keeper.rs +++ b/secretspec/src/provider/keeper.rs @@ -448,7 +448,7 @@ impl Provider for KeeperProvider { } } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/keyring.rs b/secretspec/src/provider/keyring.rs index 0b14999af..7791a3196 100644 --- a/secretspec/src/provider/keyring.rs +++ b/secretspec/src/provider/keyring.rs @@ -157,7 +157,7 @@ impl Provider for KeyringProvider { Ok(std::borrow::Cow::Owned(coords)) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/lastpass.rs b/secretspec/src/provider/lastpass.rs index 514a65139..d0a5e8b55 100644 --- a/secretspec/src/provider/lastpass.rs +++ b/secretspec/src/provider/lastpass.rs @@ -254,7 +254,7 @@ impl Provider for LastPassProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index b2251b10d..573cc82b6 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -97,16 +97,20 @@ pub use macros::{ pub use registry::ProviderInfo; #[cfg(feature = "cli")] pub use registry::providers; -pub use traits::{DiscoveryContext, ProducedValuePersistence, Provider}; +#[cfg(test)] +pub(crate) use traits::get_each; +pub use traits::{DiscoveryContext, ProducedValuePersistence, Provider, ProviderValue}; // Shared implementation support used by provider backends and orchestration. pub(crate) use address::{OwnedAddress, flat_item}; #[cfg(any(feature = "openbao", feature = "scaleway", feature = "vault"))] pub(crate) use credentials::preferred_env; pub(crate) use credentials::{ProviderCredentials, credential_or_env, credential_or_envs}; -pub(crate) use factory::provider_from_spec; +pub(crate) use factory::{ + external_provider_from_spec, provider_from_spec, provider_url_from_spec, reject_uri_credential, +}; #[cfg(test)] -pub(crate) use factory::provider_from_url; +pub(crate) use factory::{provider_from_url, provider_from_url_with_discovery}; #[cfg(any(feature = "awssm", feature = "infisical", feature = "scaleway", test))] pub(crate) use path::join_slash_path; pub(crate) use preflight::ProviderWithPreflight; @@ -114,22 +118,12 @@ pub(crate) use preflight::ProviderWithPreflight; pub(crate) use registry::spec_provider_reads; pub(crate) use registry::{ credential_names_for_spec, deleting_provider_names, provider_display_name_for_spec, - spec_names_known_provider, spec_provider_deletes, + spec_names_known_provider, spec_uses_dynamic_credentials, static_delete_capability, }; -#[cfg(any( - feature = "akv", - feature = "awsps", - feature = "awssm", - feature = "gcsm", - feature = "infisical", - feature = "scaleway" -))] pub(crate) use runtime::block_on; #[cfg(test)] pub(crate) use traits::GET_EACH_CONCURRENCY_ENV; -#[cfg(test)] -pub(crate) use traits::get_each; -#[cfg(any(feature = "infisical", feature = "openbao", feature = "vault"))] +pub(crate) use traits::exists_each; pub(crate) use traits::get_each_with; pub(crate) use traits::{get_each_concurrency, map_concurrently, same_storage_container}; pub(crate) use url::{ProviderUrl, URI_ENCODE_SET}; @@ -152,6 +146,7 @@ pub mod bws; pub mod dashlane; pub mod dotenv; pub mod env; +pub mod external; pub mod file; pub mod fly; #[cfg(feature = "gcsm")] diff --git a/secretspec/src/provider/null.rs b/secretspec/src/provider/null.rs index 854e81e0b..5dca422d6 100644 --- a/secretspec/src/provider/null.rs +++ b/secretspec/src/provider/null.rs @@ -108,7 +108,7 @@ impl Provider for NullProvider { ProducedValuePersistence::Ephemeral } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/onepassword.rs b/secretspec/src/provider/onepassword.rs index ffb4a78d2..e925626f6 100644 --- a/secretspec/src/provider/onepassword.rs +++ b/secretspec/src/provider/onepassword.rs @@ -1199,7 +1199,7 @@ impl Provider for OnePasswordProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/openbao.rs b/secretspec/src/provider/openbao.rs index 7d1ffc138..f8c556a25 100644 --- a/secretspec/src/provider/openbao.rs +++ b/secretspec/src/provider/openbao.rs @@ -134,7 +134,7 @@ impl Provider for OpenBaoProvider { self.core.with_credentials(credentials); } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/pass.rs b/secretspec/src/provider/pass.rs index 7921e0bcf..9b077e3ca 100644 --- a/secretspec/src/provider/pass.rs +++ b/secretspec/src/provider/pass.rs @@ -135,7 +135,7 @@ impl Provider for PassProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/passbolt.rs b/secretspec/src/provider/passbolt.rs index 127b633ca..0dba06a27 100644 --- a/secretspec/src/provider/passbolt.rs +++ b/secretspec/src/provider/passbolt.rs @@ -512,7 +512,7 @@ impl Provider for PassboltProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/preflight.rs b/secretspec/src/provider/preflight.rs index 2c952203e..24bb94329 100644 --- a/secretspec/src/provider/preflight.rs +++ b/secretspec/src/provider/preflight.rs @@ -1,9 +1,12 @@ -use super::{Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderCredentials}; +use super::{ + Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderCredentials, + ProviderValue, +}; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; use secrecy::SecretString; use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, LazyLock, Mutex, OnceLock}; /// Return type from provider factories that pairs a provider with an @@ -72,7 +75,7 @@ impl AuthCheckCache { /// Auth probes shared across provider instances (see /// [`Provider::auth_scope_key`]), keyed by provider name plus scope. -static PREFLIGHT_AUTH_CACHE: LazyLock> = +static PREFLIGHT_AUTH_CACHE: LazyLock> = LazyLock::new(AuthCheckCache::default); /// Wrapper that runs a preflight check exactly once before any provider @@ -101,7 +104,7 @@ impl PreflightGuard { // secret's `providers` chain creates all reuse one probe. if let Some(scope) = self.inner.auth_scope_key() { return PREFLIGHT_AUTH_CACHE - .check((self.inner.name(), scope), || { + .check((self.inner.name().to_string(), scope), || { f().map_err(|e| crate::error::display_error_chain(&e)) }) .map_err(SecretSpecError::ProviderOperationFailed); @@ -126,6 +129,10 @@ impl Provider for PreflightGuard { self.inner.supported_coords() } + fn supports_coord(&self, name: &str) -> bool { + self.inner.supports_coord(name) + } + fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { // Pure naming, no I/O: needs no auth preflight. self.inner.resolve_coords(addr) @@ -141,6 +148,20 @@ impl Provider for PreflightGuard { self.inner.get(addr) } + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + self.check()?; + self.inner.get_with_metadata(addr) + } + + fn supports_read(&self) -> bool { + self.inner.supports_read() + } + + fn exists(&self, addr: Address<'_>) -> Result { + self.check()?; + self.inner.exists(addr) + } + fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { self.check()?; self.inner.set(addr, value) @@ -193,7 +214,7 @@ impl Provider for PreflightGuard { self.inner.auth_scope_key() } - fn name(&self) -> &'static str { + fn name(&self) -> &str { self.inner.name() } @@ -230,10 +251,18 @@ impl Provider for PreflightGuard { self.inner.set_reason(reason); } + fn set_requested_authorization_duration(&self, duration: Option) { + self.inner.set_requested_authorization_duration(duration); + } + fn set_caller(&self, caller: Option) { self.inner.set_caller(caller); } + fn set_project(&self, project: &str) { + self.inner.set_project(project); + } + fn set_profile(&self, profile: &str) { self.inner.set_profile(profile); } @@ -255,6 +284,19 @@ impl Provider for PreflightGuard { self.check()?; self.inner.get_many(requests) } + + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + self.check()?; + self.inner.get_many_with_metadata(requests) + } + + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + self.check()?; + self.inner.exists_many(requests) + } } #[cfg(test)] @@ -292,7 +334,7 @@ mod tests { Ok(()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "profile-recording" } diff --git a/secretspec/src/provider/protonpass.rs b/secretspec/src/provider/protonpass.rs index d541955a4..b29a5b72d 100644 --- a/secretspec/src/provider/protonpass.rs +++ b/secretspec/src/provider/protonpass.rs @@ -372,7 +372,7 @@ impl Provider for ProtonPassProvider { }) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/registry.rs b/secretspec/src/provider/registry.rs index d16b71227..d5d6e1144 100644 --- a/secretspec/src/provider/registry.rs +++ b/secretspec/src/provider/registry.rs @@ -109,15 +109,35 @@ pub(crate) fn spec_names_known_provider(spec: &str) -> Result { file::MISSING_DIRECTORY_ERROR.to_string(), )); } - Ok(registration_for_scheme(scheme).is_some()) + if registration_for_scheme(scheme).is_some() { + return Ok(true); + } + Ok(super::external::discover(scheme)?.is_some()) } /// The semantic credential names accepted by the provider named by `spec`, or /// an empty slice for an unknown scheme. Lets alias validation reject a /// declaration the provider would silently ignore. -pub(crate) fn credential_names_for_spec(spec: &str) -> &'static [&'static str] { +pub(crate) fn credential_names_for_spec(spec: &str) -> Result> { + let (scheme, _) = split_spec(spec); + if let Some(registration) = registration_for_scheme(scheme) { + return Ok(registration + .credential_names + .iter() + .map(|name| (*name).to_string()) + .collect()); + } + super::external::discover(scheme).map(|_| Vec::new()) +} + +/// Whether `spec` names an external endpoint whose credential requirements +/// are negotiated at runtime rather than registered statically (0.20+). +pub(crate) fn spec_uses_dynamic_credentials(spec: &str) -> Result { let (scheme, _) = split_spec(spec); - registration_for_scheme(scheme).map_or(&[], |reg| reg.credential_names) + if registration_for_scheme(scheme).is_some() { + return Ok(false); + } + Ok(super::external::discover(scheme)?.is_some()) } /// Whether the provider named by `spec` can return plaintext secret values. @@ -134,9 +154,9 @@ pub(crate) fn spec_provider_reads(spec: &str) -> bool { /// /// Read from registration metadata so routing can validate an invalidatable /// store while planning, before a provider is constructed. -pub(crate) fn spec_provider_deletes(spec: &str) -> bool { +pub(crate) fn static_delete_capability(spec: &str) -> Option { let (scheme, _) = split_spec(spec); - registration_for_scheme(scheme).is_some_and(|reg| reg.deletes) + registration_for_scheme(scheme).map(|registration| registration.deletes) } /// The names of every provider that implements deletion, sorted. Used to say diff --git a/secretspec/src/provider/runtime.rs b/secretspec/src/provider/runtime.rs index a8a022af7..47e4ee42e 100644 --- a/secretspec/src/provider/runtime.rs +++ b/secretspec/src/provider/runtime.rs @@ -1,15 +1,19 @@ /// Executes an async future in a blocking context. /// /// If already inside a tokio runtime, uses `block_in_place` with the -/// existing runtime handle. Otherwise, creates a new runtime. +/// existing runtime handle. Otherwise, uses a process-wide runtime so +/// background tasks owned by long-lived providers remain alive between calls. #[allow(dead_code)] pub(crate) fn block_on(future: F) -> F::Output { + static PROVIDER_RUNTIME: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to create provider runtime") + }); match tokio::runtime::Handle::try_current() { Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)), - Err(_) => tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create tokio runtime") - .block_on(future), + Err(_) => PROVIDER_RUNTIME.block_on(future), } } diff --git a/secretspec/src/provider/scaleway.rs b/secretspec/src/provider/scaleway.rs index 70a85e8c7..b7f713ed9 100644 --- a/secretspec/src/provider/scaleway.rs +++ b/secretspec/src/provider/scaleway.rs @@ -494,7 +494,7 @@ impl Provider for ScalewayProvider { self.credentials = credentials; } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/sops/mod.rs b/secretspec/src/provider/sops/mod.rs index b98761a15..887be3170 100644 --- a/secretspec/src/provider/sops/mod.rs +++ b/secretspec/src/provider/sops/mod.rs @@ -665,7 +665,7 @@ impl Provider for SopsProvider { Ok(values) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/sops/tests.rs b/secretspec/src/provider/sops/tests.rs index 7f11e0ff4..c79a6ec7b 100644 --- a/secretspec/src/provider/sops/tests.rs +++ b/secretspec/src/provider/sops/tests.rs @@ -841,7 +841,7 @@ fn test_sops_provider_advertises_credentials() { "google_oauth_access_token", ]; assert_eq!( - crate::provider::credential_names_for_spec("sops://secrets.enc.yaml"), + crate::provider::credential_names_for_spec("sops://secrets.enc.yaml").unwrap(), expected ); } diff --git a/secretspec/src/provider/systemd_credential.rs b/secretspec/src/provider/systemd_credential.rs index 8a72e23c4..008d39359 100644 --- a/secretspec/src/provider/systemd_credential.rs +++ b/secretspec/src/provider/systemd_credential.rs @@ -182,7 +182,7 @@ impl Provider for SystemdCredentialProvider { )) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/provider/tests.rs b/secretspec/src/provider/tests.rs index 79148505a..c3fc50007 100644 --- a/secretspec/src/provider/tests.rs +++ b/secretspec/src/provider/tests.rs @@ -56,7 +56,7 @@ impl Provider for MockProvider { Ok(self.storage.lock().unwrap().remove(&item).is_some()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "mock" } @@ -116,7 +116,7 @@ impl Provider for CountingProvider { Ok(()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "counting" } @@ -198,7 +198,7 @@ impl Provider for MemTestProvider { Ok(MEM_STORE.lock().unwrap().remove(&item).is_some()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -281,7 +281,7 @@ impl Provider for SlowTestProvider { MemTestProvider.delete(addr) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -300,6 +300,7 @@ impl Provider for SlowTestProvider { pub(crate) struct StatefulTestProvider { snapshot: std::sync::OnceLock>, reason: Mutex>, + requested_authorization_duration: Mutex>, caller: Mutex>, } pub(crate) struct StatefulTestConfig; @@ -309,6 +310,9 @@ static STATEFUL_REASON_READS: std::sync::LazyLock>>>, > = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +static STATEFUL_AUTHORIZATION_DURATION_READS: std::sync::LazyLock< + Mutex>>>, +> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); impl TryFrom<&super::ProviderUrl> for StatefulTestConfig { type Error = crate::SecretSpecError; @@ -323,6 +327,7 @@ impl StatefulTestProvider { Self { snapshot: std::sync::OnceLock::new(), reason: Mutex::new(None), + requested_authorization_duration: Mutex::new(None), caller: Mutex::new(None), } } @@ -362,6 +367,12 @@ impl Provider for StatefulTestProvider { .entry(item.clone()) .or_default() .push(self.caller.lock().unwrap().clone()); + STATEFUL_AUTHORIZATION_DURATION_READS + .lock() + .unwrap() + .entry(item.clone()) + .or_default() + .push(*self.requested_authorization_duration.lock().unwrap()); let snapshot = self .snapshot .get_or_init(|| MEM_STORE.lock().unwrap().clone()); @@ -378,7 +389,7 @@ impl Provider for StatefulTestProvider { MemTestProvider.delete(addr) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -390,6 +401,10 @@ impl Provider for StatefulTestProvider { *self.reason.lock().unwrap() = reason; } + fn set_requested_authorization_duration(&self, duration: Option) { + *self.requested_authorization_duration.lock().unwrap() = duration; + } + fn set_caller(&self, caller: Option) { *self.caller.lock().unwrap() = caller; } @@ -411,6 +426,16 @@ pub(crate) fn take_stateful_caller_reads(item: &str) -> Vec Vec> { + STATEFUL_AUTHORIZATION_DURATION_READS + .lock() + .unwrap() + .remove(item) + .unwrap_or_default() +} + /// Registered provider that reads and deletes [`MEM_STORE`] like `memtest://` /// but always fails to write (`failwrite://`). /// @@ -470,7 +495,7 @@ impl Provider for FailWriteProvider { MemTestProvider.delete(addr) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -535,7 +560,7 @@ impl Provider for FailDeleteProvider { )) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -619,7 +644,7 @@ impl Provider for ExpiringProvider { MemTestProvider.delete(addr) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -739,7 +764,7 @@ impl Provider for PeakConcurrencyProvider { Ok(()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "peak" } @@ -2286,6 +2311,19 @@ fn dotenv_write_read_symmetry() { assert_write_read_symmetry(&provider); } +#[test] +fn compiled_provider_cannot_be_shadowed_by_external_discovery() { + use super::{ProviderCredentials, ProviderUrl, provider_from_url_with_discovery}; + + let directory = TempDir::new().unwrap(); + let url = ProviderUrl::new(url::Url::from_file_path(directory.path().join(".env")).unwrap()); + let provider = provider_from_url_with_discovery(&url, ProviderCredentials::new(), |_| { + panic!("external discovery must not run for a compiled provider scheme") + }) + .unwrap(); + assert_eq!(provider.name(), "file"); +} + #[test] fn file_write_read_symmetry() { use super::file::{FileConfig, FileProvider}; @@ -2334,7 +2372,7 @@ impl Provider for DeletingProvider { true } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "deleting" } diff --git a/secretspec/src/provider/traits.rs b/secretspec/src/provider/traits.rs index b4fd826a5..8ffb43fa4 100644 --- a/secretspec/src/provider/traits.rs +++ b/secretspec/src/provider/traits.rs @@ -1,10 +1,10 @@ -use super::address::reject_unsupported_coords; +use super::address::unsupported_coord_error; use super::{Address, ProviderCredentials}; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; use secrecy::SecretString; use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Context supplied when a provider discovers secret declarations. /// @@ -40,6 +40,26 @@ pub enum ProducedValuePersistence { Ephemeral, } +/// A provider value with an optional authoritative validity bound. +/// +/// `expires_at_unix_ms` is when the secret itself expires according to the +/// provider. `None` means no bound is known. Resolver cache freshness is +/// separate metadata and must never be placed here. +#[derive(Clone)] +pub struct ProviderValue { + pub value: SecretString, + pub expires_at_unix_ms: Option, +} + +impl ProviderValue { + pub fn new(value: SecretString, expires_at_unix_ms: Option) -> Self { + Self { + value, + expires_at_unix_ms, + } + } +} + /// Trait defining the interface for secret storage providers. /// /// All secret storage backends must implement this trait to integrate with SecretSpec. @@ -90,6 +110,16 @@ pub trait Provider: Send + Sync { &[] } + /// Returns whether this provider understands an optional native-address + /// coordinate. Available since SecretSpec 0.20. + /// + /// Static providers inherit the existing slice-based behavior. External + /// providers override this hook because their coordinate list is selected + /// during protocol initialization and is therefore owned by the instance. + fn supports_coord(&self, name: &str) -> bool { + self.supported_coords().contains(&name) + } + /// Resolves any [`Address`] to this store's native coordinates: a `ref`'s /// coordinates pass through as-is, a convention address is compiled via /// [`convention_address`](Provider::convention_address). Coordinates @@ -104,7 +134,14 @@ pub trait Provider: Send + Sync { key, } => Cow::Owned(self.convention_address(project, profile, key)?), }; - reject_unsupported_coords(self.name(), &coords, self.supported_coords())?; + for (name, value) in coords.coordinates() { + if name == "item" || value.is_none() { + continue; + } + if !self.supports_coord(name) { + return Err(unsupported_coord_error(self.name(), &coords, name)); + } + } Ok(coords) } @@ -144,6 +181,37 @@ pub trait Provider: Send + Sync { /// ``` fn get(&self, addr: Address<'_>) -> Result>; + /// Retrieves a value together with its provider-reported validity bound. + /// + /// Available starting with SecretSpec 0.20. Existing providers inherit a + /// compatibility implementation with unknown expiry. Providers issuing or + /// reading time-bounded credentials override this method; callers use it in + /// preference to [`get`](Provider::get) when they can preserve metadata. + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + self.get(addr) + .map(|value| value.map(|value| ProviderValue::new(value, None))) + } + + /// Whether this provider can return plaintext values through + /// [`get`](Provider::get) or [`get_many`](Provider::get_many). + /// + /// Available starting with SecretSpec 0.20. Write-only stores override this + /// to return `false`; callers that only need presence can then use + /// [`exists`](Provider::exists) without accidentally attempting a value + /// read. This capability check never returns a secret value. + fn supports_read(&self) -> bool { + true + } + + /// Tests whether one addressed secret exists without requiring its value. + /// + /// Available starting with SecretSpec 0.20. Readable providers inherit the + /// compatibility implementation. Write-only providers must override it with + /// a value-free backend operation. + fn exists(&self, addr: Address<'_>) -> Result { + Ok(self.get(addr)?.is_some()) + } + /// Stores a secret value at `addr`. /// /// # Returns @@ -324,7 +392,7 @@ pub trait Provider: Send + Sync { /// Returns the name of this provider. /// /// This should match the name registered with the provider macro. - fn name(&self) -> &'static str; + fn name(&self) -> &str; /// Returns the full URI representation of this provider. /// @@ -430,6 +498,13 @@ pub trait Provider: Send + Sync { /// [`Secrets::with_reason`]: crate::Secrets::with_reason fn set_reason(&self, _reason: Option) {} + /// Records the app-requested authorization lifetime for approval surfaces. + /// Available starting with SecretSpec 0.20. + /// + /// This is an untrusted default: the provider and user decide the actual + /// grant lifetime. The default implementation ignores it. + fn set_requested_authorization_duration(&self, _duration: Option) {} + /// Records structured context about the software integration invoking /// SecretSpec, such as `git` performing `credential_get` for `github.com`. /// @@ -443,6 +518,16 @@ pub trait Provider: Send + Sync { /// Available since SecretSpec 0.20. fn set_caller(&self, _caller: Option) {} + /// Records the declared project for this provider session. Available + /// starting with SecretSpec 0.20. + /// + /// This is resolver-declared context for provider policy, audit, and + /// approval surfaces. It is not an authenticated application identity and + /// must not be used as one. Convention addresses carry the same project; + /// native addresses need this session context because their coordinates do + /// not. + fn set_project(&self, _project: &str) {} + /// Records the profile this session resolves under. Available starting with /// SecretSpec 0.20. /// @@ -544,6 +629,39 @@ pub trait Provider: Send + Sync { fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { get_each(self, requests) } + + /// Batch form of [`get_with_metadata`](Provider::get_with_metadata). + /// + /// Existing providers retain their optimized `get_many` implementation and + /// report unknown expiry. A provider with per-value lifetime metadata must + /// override this method as well as the single-value form. + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + self.get_many(requests).map(|values| { + values + .into_iter() + .map(|(name, value)| (name, ProviderValue::new(value, None))) + .collect() + }) + } + + /// Tests a batch of addressed secrets for presence, returning the request + /// names that exist. + /// + /// Available starting with SecretSpec 0.20. Readable providers reuse their + /// batch read surface. Write-only providers use bounded concurrent + /// [`exists`](Provider::exists) calls unless they override this with a native + /// listing operation. + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + if self.supports_read() { + return self + .get_many(requests) + .map(|values| values.into_keys().collect()); + } + exists_each(self, requests) + } } /// Returns a stable lexical identity for a filesystem store that may not exist @@ -650,16 +768,42 @@ pub(crate) fn get_each( get_each_with(requests, |addr| provider.get(addr)) } +/// Bounded, deduplicating fallback for write-only providers that expose only a +/// single-address presence operation. +pub(crate) fn exists_each( + provider: &P, + requests: &[(&str, Address<'_>)], +) -> Result> { + let mut groups: HashMap, Vec<&str>> = HashMap::new(); + for (name, addr) in requests { + groups.entry(*addr).or_default().push(name); + } + let groups: Vec<(Address<'_>, Vec<&str>)> = groups.into_iter().collect(); + let checked: Vec<(Vec<&str>, Result)> = + map_concurrently(&groups, get_each_concurrency(), |(addr, names)| { + (names.clone(), provider.exists(*addr)) + }); + + let mut present = HashSet::new(); + for (names, result) in checked { + if result? { + present.extend(names.into_iter().map(str::to_string)); + } + } + Ok(present) +} + /// [`get_each`] with an operation-scoped fetch function. /// /// Providers can use this when the per-address reads need to share state that /// belongs to exactly one `get_many` call, such as a short-lived login token. -pub(crate) fn get_each_with<'a, F>( +pub(crate) fn get_each_with<'a, F, T>( requests: &[(&str, Address<'a>)], fetch: F, -) -> Result> +) -> Result> where - F: Fn(Address<'a>) -> Result> + Sync, + F: Fn(Address<'a>) -> Result> + Sync, + T: Clone + Send, { let mut groups: HashMap, Vec<&str>> = HashMap::new(); for (name, addr) in requests { @@ -673,7 +817,7 @@ where // One address is the common case (a single secret, or several sharing a // `ref`); `map_concurrently` keeps it on this thread. Larger sets fan out in // capped waves so they do not stampede a provider. - let fetched: Vec<(Vec<&str>, Result>)> = + let fetched: Vec<(Vec<&str>, Result>)> = map_concurrently(&groups, get_each_concurrency(), |(addr, names)| { (names.clone(), fetch(*addr)) }); @@ -696,6 +840,9 @@ impl Provider for std::sync::Arc { fn supported_coords(&self) -> &'static [&'static str] { (**self).supported_coords() } + fn supports_coord(&self, name: &str) -> bool { + (**self).supports_coord(name) + } fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { (**self).resolve_coords(addr) } @@ -705,6 +852,15 @@ impl Provider for std::sync::Arc { fn get(&self, addr: Address<'_>) -> Result> { (**self).get(addr) } + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + (**self).get_with_metadata(addr) + } + fn supports_read(&self) -> bool { + (**self).supports_read() + } + fn exists(&self, addr: Address<'_>) -> Result { + (**self).exists(addr) + } fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { (**self).set(addr, value) } @@ -740,7 +896,7 @@ impl Provider for std::sync::Arc { fn auth_scope_key(&self) -> Option { (**self).auth_scope_key() } - fn name(&self) -> &'static str { + fn name(&self) -> &str { (**self).name() } fn uri(&self) -> String { @@ -769,9 +925,15 @@ impl Provider for std::sync::Arc { fn set_reason(&self, reason: Option) { (**self).set_reason(reason); } + fn set_requested_authorization_duration(&self, duration: Option) { + (**self).set_requested_authorization_duration(duration); + } fn set_caller(&self, caller: Option) { (**self).set_caller(caller); } + fn set_project(&self, project: &str) { + (**self).set_project(project); + } fn set_profile(&self, profile: &str) { (**self).set_profile(profile); } @@ -781,4 +943,13 @@ impl Provider for std::sync::Arc { fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { (**self).get_many(requests) } + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + (**self).get_many_with_metadata(requests) + } + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + (**self).exists_many(requests) + } } diff --git a/secretspec/src/provider/vault.rs b/secretspec/src/provider/vault.rs index 24186b411..d94a07f36 100644 --- a/secretspec/src/provider/vault.rs +++ b/secretspec/src/provider/vault.rs @@ -122,7 +122,7 @@ impl Provider for VaultProvider { self.core.with_credentials(credentials); } - fn name(&self) -> &'static str { + fn name(&self) -> &str { Self::PROVIDER_NAME } diff --git a/secretspec/src/resolve.rs b/secretspec/src/resolve.rs index 50a96deaa..4581d7ed4 100644 --- a/secretspec/src/resolve.rs +++ b/secretspec/src/resolve.rs @@ -241,7 +241,7 @@ fn dispatch(request_json: &str) -> serde_json::Value { /// `{"ok": false, "error": {"kind", "message"}}`. /// /// This is the shared JSON boundary used by every native binding (the C ABI in -/// `secretspec-ffi` and the napi-rs Node addon), so the envelope contract is +/// `libsecretspec` and the napi-rs Node addon), so the envelope contract is /// defined in exactly one place. The request accepts optional `path`, /// `provider`, `profile`, `scope`, `reason`, `caller` (SecretSpec 0.20+), /// `no_values`, and `mode` @@ -251,7 +251,7 @@ fn dispatch(request_json: &str) -> serde_json::Value { /// `report` response never does. pub fn resolve_json(request_json: &str) -> String { // Catch panics here, at the one place both native boundaries funnel through - // (the C ABI in `secretspec-ffi` and the napi-rs Node addon). Unwinding across + // (the C ABI in `libsecretspec` and the napi-rs Node addon). Unwinding across // either is undefined behavior, and turning a panic into the same // `{"ok":false,"error":...}` envelope every binding already parses means all // bindings behave identically — the C ABI no longer needs to be the only one diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 4db49c4f1..146c942c6 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -1,7 +1,7 @@ //! Core secrets management functionality use crate::CallerContext; -use crate::audit::{AuditAction, AuditContext, AuditLogger, AuditOutcome}; +use crate::audit::{AuditAction, AuditContext, AuditLogger, AuditOutcome, AuditPurpose}; use crate::cache::{self, CacheEntryStatus, CacheOwnership}; use crate::compiled_spec::{CompiledSpec, MissingPolicy}; use crate::config::{ @@ -12,7 +12,7 @@ use crate::error::{Result, SecretSpecError}; use crate::plan::{PlannedSecret, ResolutionPlan, ResolvedCache, Route}; use crate::provider::{ Address, OwnedAddress, ProducedValuePersistence, Provider as ProviderTrait, - ProviderCredentials, same_storage_container, + ProviderCredentials, ProviderValue, same_storage_container, }; use crate::report::{ResolutionReport, ResolutionStatus, SecretResolution}; use crate::resolve::{ @@ -29,6 +29,8 @@ use secrecy::{ExposeSecret, SecretSlice, SecretString}; use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM}; #[cfg(unix)] use signal_hook::iterator::{Handle as SignalHandle, Signals}; +#[cfg(feature = "cli")] +use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::env; use std::io::{self, IsTerminal, Read, Write}; @@ -251,7 +253,10 @@ fn group_names(group: &[&PlannedSecret]) -> String { /// What a stored cache entry can do for the read that found it. enum CachedEntry { /// Fresh, and written for this route: serve it. - Fresh(SecretString), + Fresh { + value: ProviderValue, + refresh_at_unix_ms: Option, + }, /// A SecretSpec entry no read will serve: expired regardless of owner, or /// ours but unreadable or written for another route or freshness policy. /// Safe to drop. @@ -283,7 +288,14 @@ fn cached_entry( &route_fingerprint, cache.max_age_secs, ) { - Ok(CacheEntryStatus::Fresh(value)) => CachedEntry::Fresh(value), + Ok(CacheEntryStatus::Fresh { + value, + refresh_at_unix_ms, + expires_at_unix_ms, + }) => CachedEntry::Fresh { + value: ProviderValue::new(value, expires_at_unix_ms), + refresh_at_unix_ms, + }, Ok(CacheEntryStatus::Stale) => CachedEntry::Stale, Ok(CacheEntryStatus::OursUnreadable) => { cache_read_warning(&planned.name, "the cache entry could not be read"); @@ -356,12 +368,169 @@ type ProviderCredentialsKey = (String, String); type ProviderCredentialsSlot = Arc>>; type ProviderKey = (String, String); type ProviderSlot = Arc>>>; + +struct BrokerCredentialSource { + source: CredentialSource, + provider: Arc, +} + +/// Host-side resolver for credentials requested by one external provider. +/// +/// A manifest mapping is an explicit override, not a declaration of the +/// endpoint's complete credential vocabulary. Requests without a mapping use +/// SecretSpec's provider-private keyring namespace. Values are memoized only +/// for the lifetime of the provider operation that owns this broker. +struct SecretsProviderCredentialBroker { + alias: String, + scheme: String, + project: String, + profile: String, + configured: HashMap, + fallback: crate::provider::external::KeyringCredentialBroker, + cache: Mutex>>, + audit: Option>, + reason: Option, + caller: Option, + #[cfg(feature = "cli")] + purpose: Option, +} + +impl SecretsProviderCredentialBroker { + fn record( + &self, + name: &str, + provider_uri: String, + reference: Option<&NativeAddress>, + outcome: AuditOutcome, + error_kind: Option<&str>, + ) { + let Some(logger) = &self.audit else { return }; + #[cfg(feature = "cli")] + let purpose = self.purpose.as_ref().map(|purpose| AuditPurpose { + consumer: &purpose.consumer, + operation: &purpose.operation, + host: purpose.host.as_deref(), + path: purpose.path.as_deref(), + }); + #[cfg(not(feature = "cli"))] + let purpose: Option> = None; + logger.record( + AuditAction::Get, + AuditContext { + project: &self.project, + profile: &self.profile, + scope: None, + key: Some(name), + keys: &[], + command: Some("credential"), + provider_uri: Some(provider_uri), + reference: reference.map(NativeAddress::render), + outcome, + error_kind, + interaction: None, + reason: self.reason.as_deref(), + caller: self.caller.as_ref(), + purpose, + }, + ); + } +} + +impl crate::provider::external::ProviderCredentialBroker for SecretsProviderCredentialBroker { + fn get( + &self, + scheme: &str, + request: &secretspec_ipc::protocol::callback::CredentialParams, + ) -> Result> { + // The responder supplies the discovered endpoint's scheme, but retain + // this check at the authority boundary so a future caller cannot reuse + // a broker across provider principals. + if scheme != self.scheme { + return Err(SecretSpecError::ProviderOperationFailed( + "external provider credential principal changed".to_string(), + )); + } + let identity = ( + if self.configured.contains_key(&request.name) { + String::new() + } else { + request.scope.clone() + }, + request.name.clone(), + ); + // Hold the per-operation cache lock through population. Credential + // callbacks may be concurrent; single-flight prevents duplicate source + // reads or duplicate interactive prompts for one identity. + let mut cache = self + .cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(cached) = cache.get(&identity).cloned() { + return Ok(cached); + } + + let value = if let Some(configured) = self.configured.get(&request.name) { + let fetched = configured + .provider + .get(configured.source.address(&self.project, &request.name)); + let (outcome, error_kind) = match &fetched { + Ok(Some(_)) => (AuditOutcome::Found, None), + Ok(None) => (AuditOutcome::Missing, None), + Err(error) => (AuditOutcome::Error, Some(error.kind())), + }; + self.record( + &request.name, + configured.provider.uri(), + configured.source.reference.as_ref(), + outcome, + error_kind, + ); + match fetched? { + Some(value) => Some(value), + None => { + return Err(credential_missing_error( + &request.name, + &self.alias, + &configured.source.location(&self.project, &request.name), + )); + } + } + } else { + let address = crate::provider::external::brokered_credential_address( + scheme, + &request.scope, + &request.name, + ); + let fetched = crate::provider::external::ProviderCredentialBroker::get( + &self.fallback, + scheme, + request, + ); + let (outcome, error_kind) = match &fetched { + Ok(Some(_)) => (AuditOutcome::Found, None), + Ok(None) => (AuditOutcome::Missing, None), + Err(error) => (AuditOutcome::Error, Some(error.kind())), + }; + self.record( + &request.name, + "keyring://".to_string(), + Some(&address), + outcome, + error_kind, + ); + fetched? + }; + + cache.insert(identity, value.clone()); + Ok(value) + } +} type GroupFetch<'a> = ( Option<&'a str>, Vec<&'a PlannedSecret>, Box, ); -type FallbackReadResult = Result<(Option, Option, Option)>; +type FallbackReadResult = Result<(Option, Option, Option)>; struct PreparedImport { planned: PlannedSecret, @@ -559,6 +728,110 @@ enum PreparedSecret { }, } +/// Named resolution with temporary-file ownership retained by the caller. +/// The resolver converts these owners into session leases; the embedded API +/// persists them to preserve its existing one-shot path behavior. +pub(crate) enum OwnedNamedResolution { + Undeclared, + Missing { + required: bool, + }, + Value { + value: String, + source: ResolvedSource, + source_provider: Option, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + expires_at_unix_ms: Option, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + refresh_at_unix_ms: Option, + supporting_files: Vec, + }, + File { + file: tempfile::NamedTempFile, + source: ResolvedSource, + source_provider: Option, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + expires_at_unix_ms: Option, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + refresh_at_unix_ms: Option, + supporting_files: Vec, + }, +} + +impl OwnedNamedResolution { + fn into_embedded(self) -> Result { + match self { + Self::Undeclared => Ok(NamedResolution::Undeclared), + Self::Missing { required } => Ok(NamedResolution::Missing { required }), + Self::Value { + value, + source, + source_provider, + supporting_files, + .. + } => { + keep_owned_files(supporting_files)?; + Ok(NamedResolution::Resolved(ResolvedSecret { + value: Some(value), + path: None, + as_path: false, + source, + source_provider, + })) + } + Self::File { + file, + source, + source_provider, + supporting_files, + .. + } => { + keep_owned_files(supporting_files)?; + let path = file + .into_temp_path() + .keep() + .map_err(|error| SecretSpecError::Io(error.error))?; + Ok(NamedResolution::Resolved(ResolvedSecret { + value: None, + path: Some(path.to_string_lossy().into_owned()), + as_path: true, + source, + source_provider, + })) + } + } + } +} + +/// Where a write landed. Reported instead of printed, so the caller that knows +/// whether it owns a terminal decides how the destination is announced. +pub(crate) struct StoredSecret { + pub(crate) profile: String, + /// Display name of the provider that took the write, as the CLI prints it. + pub(crate) provider_name: String, + /// Credential-free URI of the same provider, as audit records it. + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + pub(crate) provider_uri: String, +} + +/// The outcome of removing one stored value, with the same attribution a write +/// reports. +pub(crate) struct DeletedSecret { + /// `false` when the store held nothing: removal is idempotent. + pub(crate) deleted: bool, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + pub(crate) provider_uri: String, +} + +fn keep_owned_files(files: Vec) -> Result<()> { + for file in files { + file.into_temp_path() + .keep() + .map_err(|error| SecretSpecError::Io(error.error))?; + } + Ok(()) +} + /// Whether a resolved string came from a storage boundary and is eligible for /// decoding, or is already the logical value produced inside SecretSpec. #[derive(Clone, Copy)] @@ -619,8 +892,14 @@ pub struct Secrets { global_config: Option, /// The provider to use (if set via builder) provider: Option, + /// Resolver sessions fix provider selection at initialization and must not + /// inherit the resolver process's provider environment. + ignore_ambient_provider: bool, /// The profile to use (if set via builder) profile: Option, + /// Resolver sessions fix profile selection at initialization and must not + /// inherit the resolver process's profile environment. + ignore_ambient_profile: bool, /// The active secret scope (if set via builder/`--scope`/`SECRETSPEC_SCOPE`). /// `None` resolves the complete profile; a scope narrows resolution to the /// intersection of the merged profile and the scope's secret list. @@ -635,6 +914,9 @@ pub struct Secrets { /// Reason for this session's secret access, forwarded to providers that /// support audit logging (set via [`Secrets::with_reason`]). reason: Option, + /// App-requested authorization lifetime, forwarded to providers as an + /// untrusted default for approval surfaces. + requested_authorization_duration: Option, /// Software integration that invoked SecretSpec. This is audit context, not /// a user-supplied reason, and never satisfies `require_reason`. caller: Option, @@ -643,15 +925,20 @@ pub struct Secrets { require_reason: RequireReason, /// Audit logger, if auditing is enabled (user-global `[audit]` config). `None` /// disables auditing. Built once per `Secrets` so all events share a session id. - audit: Option, - /// Provider credentials memoized per (profile, raw provider spec), so N - /// secrets routed at one alias fetch its credentials from the source provider - /// once per session, not once per provider build. The stored *values* are + audit: Option>, + /// Built-in provider credentials memoized per (profile, raw provider spec), + /// so N secrets routed at one alias fetch its credentials from the source + /// provider once per session, not once per provider build. External + /// providers use their operation-local lazy broker instead. The stored *values* are /// profile-independent (see `PROVIDER_CREDENTIAL_SCOPE`); the profile is kept /// in the key only so each profile's operations audit their own credential /// read. Cleared by [`Secrets::store_provider_credential`] so a freshly /// stored credential is re-read. provider_credentials_cache: ProviderCredentialsCache, + /// Memoized `provider.delete` support for external providers, keyed by + /// resolved spec. In-tree providers answer from the static registry and + /// never reach this map. + external_delete_capability: Mutex>, /// Optional CLI-owned observer for writes that are about to prompt for or /// consume a value. Library and SDK instances leave this unset, so planning /// a write never produces unsolicited output outside the CLI. @@ -659,6 +946,13 @@ pub struct Secrets { /// Test seam for deterministic run-prompt coverage. Production CLI /// instances leave this unset and use the controlling terminal. prompt_reader: Option, + /// Whether generation and prompting may write their progress lines to + /// stderr. False in resolver mode; see [`Secrets::silence_progress`]. + progress: bool, + /// Whether a resolution may store a value it produced. False when an + /// operator withheld the mutation methods; see + /// [`Secrets::refuse_produced_writes`]. + refuse_produced_writes: bool, } /// Credential-free description of one provider write, computed after routing @@ -672,7 +966,7 @@ pub(crate) struct WriteTarget { } type WriteTargetReporter = Arc; -type PromptReader = Arc Result + Send + Sync>; +type PromptReader = Arc) -> Result + Send + Sync>; /// secretspec's own opt-in for marking the current process as an agent. Lets any /// harness that the `detect-coding-agent` crate does not recognize identify itself. @@ -819,6 +1113,41 @@ struct AuditFields<'a> { reference: Option<&'a NativeAddress>, /// Stable error-variant token when the outcome is an error. error_kind: Option<&'a str>, + /// Opaque provider interaction correlation for actionable failures. + interaction: Option<&'a secretspec_ipc::InteractionReference>, +} + +#[derive(Clone)] +#[cfg(feature = "cli")] +pub(crate) struct IpcAuditPurpose { + pub consumer: String, + pub operation: String, + pub host: Option, + pub path: Option, +} + +#[cfg(feature = "cli")] +thread_local! { + static IPC_AUDIT_PURPOSE: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(feature = "cli")] +struct IpcPurposeGuard(Option); + +#[cfg(feature = "cli")] +impl Drop for IpcPurposeGuard { + fn drop(&mut self) { + IPC_AUDIT_PURPOSE.with(|slot| { + slot.replace(self.0.take()); + }); + } +} + +#[cfg(feature = "cli")] +fn with_ipc_audit_purpose(purpose: IpcAuditPurpose, operation: impl FnOnce() -> T) -> T { + let previous = IPC_AUDIT_PURPOSE.with(|slot| slot.replace(Some(purpose))); + let _guard = IpcPurposeGuard(previous); + operation() } impl Secrets { @@ -848,16 +1177,22 @@ impl Secrets { config_dir: PathBuf::from("."), global_config, provider, + ignore_ambient_provider: false, profile, + ignore_ambient_profile: false, scope: None, ignore_ambient_scope: false, reason: None, + requested_authorization_duration: None, caller: None, require_reason: RequireReason::Never, audit: None, provider_credentials_cache: ProviderCredentialsCache::default(), + external_delete_capability: Mutex::new(HashMap::new()), write_target_reporter: None, prompt_reader: None, + progress: true, + refuse_produced_writes: false, } } @@ -928,6 +1263,36 @@ impl Secrets { // A Spec already owns the exact compiled view produced by validation, // so file and Rust frontends both arrive here without recompiling. let (config, manifest) = spec.into_parts(); + Self::from_compiled_spec(config, manifest, base_dir.into(), true) + } + + /// Load an explicit path for a resolver session without consulting ambient + /// provider, profile, scope, or reason variables. + #[cfg(feature = "cli")] + pub(crate) fn load_from_ipc(path: &Path) -> Result { + let spec = Spec::try_from(path)?; + let config_dir = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let (config, manifest) = spec.into_parts(); + Self::from_compiled_spec(config, manifest, config_dir, false) + } + + /// Parse an inline resolver manifest with inheritance rooted at `base_dir`. + #[cfg(feature = "cli")] + pub(crate) fn load_inline_ipc(source: &str, base_dir: &Path) -> Result { + let config = Config::from_inline(source, base_dir)?; + let manifest = config.validate_and_compile()?; + Self::from_compiled_spec(config, manifest, base_dir.to_path_buf(), false) + } + + fn from_compiled_spec( + config: Config, + manifest: CompiledSpec, + config_dir: PathBuf, + use_ambient_session: bool, + ) -> Result { let global_config = GlobalConfig::load()?; // Auditing is a per-machine concern configured in the user-global config // (`[audit]` in ~/.config/secretspec/config.toml), not the project. It is @@ -937,23 +1302,30 @@ impl Secrets { .as_ref() .and_then(|g| g.audit.clone()) .unwrap_or_default(), - ); + ) + .map(Arc::new); Ok(Self { require_reason: config.project.require_reason.unwrap_or_default(), config, manifest, - config_dir: base_dir.into(), + config_dir, global_config, provider: None, + ignore_ambient_provider: !use_ambient_session, profile: None, + ignore_ambient_profile: !use_ambient_session, scope: None, - ignore_ambient_scope: false, - reason: env_reason(), + ignore_ambient_scope: !use_ambient_session, + reason: use_ambient_session.then(env_reason).flatten(), + requested_authorization_duration: None, caller: None, audit, provider_credentials_cache: ProviderCredentialsCache::default(), + external_delete_capability: Mutex::new(HashMap::new()), write_target_reporter: None, prompt_reader: None, + progress: true, + refuse_produced_writes: false, }) } @@ -973,14 +1345,56 @@ impl Secrets { self.write_target_reporter = Some(Arc::new(reporter)); } - #[cfg(test)] + /// Replaces controlling-terminal input for `prompt = true` declarations. + /// + /// Tests use it for deterministic coverage. The resolver uses it because it + /// has no terminal to read from at all: its stdin and stdout are the + /// protocol, so the only process that can ask a person is the one that + /// launched it, and the reader forwards the question there. + #[cfg(any(test, feature = "cli"))] pub(crate) fn set_prompt_reader( &mut self, - reader: impl Fn(&str, &str) -> Result + Send + Sync + 'static, + reader: impl Fn(&str, &str, Option<&str>) -> Result + Send + Sync + 'static, ) { self.prompt_reader = Some(Arc::new(reader)); } + /// Refuses every resolution that would produce a value and store it in a + /// provider (0.20+). + /// + /// Resolving is not always read-only: a generatable secret with no stored + /// value is minted *and written back*, and a prompted one is written back + /// after a person answers. An operator who withheld the mutation methods + /// means those writes too, so this closes the paths that would otherwise + /// let a read reach the store. It does not touch SecretSpec's own cache: + /// populating a derived copy is not a change to the secret. + #[cfg(feature = "cli")] + pub(crate) fn refuse_produced_writes(&mut self) { + self.refuse_produced_writes = true; + } + + /// Rejects a produced value that would have to be stored, before it is + /// generated or asked of a person. + fn ensure_produced_write_allowed(&self, name: &str) -> Result<()> { + if self.refuse_produced_writes { + return Err(SecretSpecError::ProducedValueWriteRefused(name.to_string())); + } + Ok(()) + } + + /// Suppresses the progress lines that generation and prompting write to + /// stderr (0.20+). + /// + /// A CLI run wants them. A resolver does not: its stderr is captured by + /// whatever launched it, which the wire protocol requires the host to treat + /// as sensitive until it has applied a redaction policy. Naming which + /// secrets a session generated or provisioned is not something to hand over + /// by default. + #[cfg(feature = "cli")] + pub(crate) fn silence_progress(&mut self) { + self.progress = false; + } + /// Sets the provider to use for secret operations /// /// This overrides the provider from global configuration. @@ -1099,6 +1513,16 @@ impl Secrets { self } + /// Requests a default authorization lifetime from providers that expose an + /// approval surface. Available starting with SecretSpec 0.20. + /// + /// The provider or approving user may choose a different lifetime. A zero + /// duration clears the request. + pub fn with_requested_authorization_duration(mut self, duration: std::time::Duration) -> Self { + self.requested_authorization_duration = (!duration.is_zero()).then_some(duration); + self + } + /// Records the software integration that invoked SecretSpec. /// /// Caller context describes *what* is requesting secrets, while @@ -1208,6 +1632,34 @@ impl Secrets { self.build_provider_for_use(spec, profile, false) } + pub(crate) fn provider_supports_delete(&self, spec: &str) -> Result { + let resolved = self.resolve_provider_spec(spec.to_string()); + if let Some(supports_delete) = crate::provider::static_delete_capability(&resolved) { + return Ok(supports_delete); + } + // An external provider advertises deletion during its handshake, so + // answering costs a full endpoint launch and teardown. Planning asks + // this for every cached alias on every plan build, so the answer is + // memoized per resolved spec for the life of this `Secrets`. + if let Some(cached) = self + .external_delete_capability + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&resolved) + { + return Ok(*cached); + } + let profile = self.resolve_profile_name(None); + let supports_delete = self + .build_provider(spec.to_string(), Some(&profile)) + .map(|provider| provider.supports_delete())?; + self.external_delete_capability + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(resolved, supports_delete); + Ok(supports_delete) + } + /// Builds the authoritative leaf selected by a planned route. /// /// The 0.19+ inline cache form is both a complete route and the alias for @@ -1235,13 +1687,22 @@ impl Secrets { // credential stores merely to discover that it cannot build the alias. self.ensure_provider_use_allowed(&spec, allow_inline_cached)?; - // When `spec` names an alias with a `credentials` map, resolve those - // values from their source providers and hand them to the built provider. + let profile = self.resolve_profile_name(profile); + let resolved = self.resolve_provider_spec(spec.clone()); + if crate::provider::spec_uses_dynamic_credentials(&resolved)? { + let broker = self.external_provider_credential_broker(&spec, &resolved, &profile)?; + let mut provider = crate::provider::external_provider_from_spec(&resolved, broker) + .map_err(|err| self.explain_unknown_provider(err, &resolved))?; + self.apply_provider_context(provider.as_mut(), Some(&profile)); + return Ok(provider); + } + + // Built-in providers declare a closed credential vocabulary. Resolve + // every configured value before construction and inject that snapshot. // Memoized per (profile, spec) so rebuilding a provider (per-secret chain walks, // interactive prompting) does not refetch the same credentials from // the source store, while a profile switch on this instance does not // reuse the other profile's credentials. - let profile = self.resolve_profile_name(profile); let key = (profile.clone(), spec.clone()); let credentials = self .provider_credentials_cache @@ -1254,6 +1715,59 @@ impl Secrets { ) } + fn external_provider_credential_broker( + &self, + alias: &str, + resolved: &str, + profile: &str, + ) -> Result> { + self.validate_credential_sources(alias)?; + let declared = self + .lookup_provider_alias_entry(alias) + .and_then(ProviderAlias::credentials) + .map(sorted_credential_entries) + .unwrap_or_default() + .into_iter() + .map(|(name, source)| (name.clone(), source.clone())) + .collect::>(); + + // Credentials sharing a source share one provider session, just as + // they did under eager resolution. Constructing these providers is + // side-effect free; their stores are contacted only from `get` below. + let mut providers: HashMap> = HashMap::new(); + let mut configured = HashMap::new(); + for (name, source) in declared { + let provider = match providers.entry(source.provider.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => entry.get().clone(), + std::collections::hash_map::Entry::Vacant(entry) => { + let provider: Arc = + Arc::from(self.build_source_provider(&source.provider)?); + entry.insert(provider.clone()); + provider + } + }; + configured.insert(name, BrokerCredentialSource { source, provider }); + } + + let scheme = crate::provider::provider_url_from_spec(resolved)? + .scheme() + .to_string(); + Ok(Arc::new(SecretsProviderCredentialBroker { + alias: alias.to_string(), + scheme, + project: self.config.project.name.clone(), + profile: profile.to_string(), + configured, + fallback: Default::default(), + cache: Mutex::new(HashMap::new()), + audit: self.audit.clone(), + reason: self.reason.clone(), + caller: self.caller.clone(), + #[cfg(feature = "cli")] + purpose: IPC_AUDIT_PURPOSE.with(|slot| slot.borrow().clone()), + })) + } + /// [`Self::build_provider`], memoized within one resolution so repeated /// builds of one spec share one provider and the connection state it holds. /// The caller owns the cache to prevent provider-local snapshots and session @@ -1309,7 +1823,8 @@ impl Secrets { /// The shared construction body behind generic, routed, and credential /// source providers: alias expansion, error enrichment, and the - /// base-dir/reason/caller hooks live only here, so those paths cannot drift. + /// base-dir/reason/caller/project/profile hooks live only here, so those + /// paths cannot drift. fn build_provider_with_credentials( &self, spec: &str, @@ -1328,9 +1843,16 @@ impl Secrets { let resolved = self.resolve_provider_spec(spec.to_string()); let mut provider = crate::provider::provider_from_spec(resolved.as_str(), credentials) .map_err(|err| self.explain_unknown_provider(err, &resolved))?; + self.apply_provider_context(provider.as_mut(), profile); + Ok(provider) + } + + fn apply_provider_context(&self, provider: &mut dyn ProviderTrait, profile: Option<&str>) { provider.with_base_dir(&self.config_dir); provider.set_reason(self.reason.clone()); + provider.set_requested_authorization_duration(self.requested_authorization_duration); provider.set_caller(self.caller.clone()); + provider.set_project(&self.config.project.name); // Context a native address cannot carry: a `ref` names coordinates only, // so a provider whose store is partitioned by something outside them // (Infisical's environment) reads the operation's profile here. It is @@ -1342,11 +1864,10 @@ impl Secrets { if let Some(profile) = profile { provider.set_profile(profile); } - Ok(provider) } - /// Resolves the credentials declared by a provider alias, fetching each - /// semantic `(name, source)` entry from its source provider. + /// Resolves the credentials declared by a built-in provider alias, fetching + /// each semantic `(name, source)` entry from its source provider. /// /// `profile` scopes the convention path a bare-string source reads from. /// Returns an empty map for a spec that is not an alias, or an alias with @@ -1450,6 +1971,31 @@ impl Secrets { .collect()) } + /// Initializes an external provider with a caller-supplied credential + /// broker. Used by `config provider login` to discover URI-specific + /// requirements and store answers without a per-alias credentials table. + #[cfg(any(feature = "cli", test))] + pub(crate) fn initialize_external_provider_with_broker( + &self, + spec: &str, + broker: Arc, + ) -> Result<()> { + self.ensure_provider_use_allowed(spec, false)?; + let resolved = self.resolve_provider_spec(spec.to_string()); + let url = crate::provider::provider_url_from_spec(&resolved)?; + let endpoint = crate::provider::external::discover(url.scheme())? + .ok_or_else(|| SecretSpecError::ProviderNotFound(url.scheme().to_string()))?; + let mut provider = crate::provider::external::ExternalProvider::from_url(endpoint, &url); + provider.with_credential_broker(broker); + provider.with_base_dir(&self.config_dir); + provider.set_reason(self.reason.clone()); + provider.set_requested_authorization_duration(self.requested_authorization_duration); + provider.set_caller(self.caller.clone()); + provider.set_project(&self.config.project.name); + provider.set_profile(&self.resolve_profile_name(None)); + provider.initialize() + } + /// Stores one provider credential at its source provider — the exact /// location [`Self::resolve_provider_credentials`] later reads it from (a `ref` /// or the profile-independent convention path for the active project). Errors @@ -1493,6 +2039,42 @@ impl Secrets { Ok(source.location(&project, name)) } + /// Stores one dynamically requested external-provider credential in the + /// provider-private keyring namespace and records the write like an + /// explicitly mapped credential source (0.20+). + #[cfg(any(feature = "cli", test))] + pub(crate) fn store_external_provider_credential( + &self, + scheme: &str, + scope: &str, + name: &str, + value: &SecretString, + ) -> Result { + self.ensure_reason_for(AuditAction::Set, Some(name))?; + let result = + crate::provider::external::store_brokered_credential(scheme, scope, name, value); + let address = crate::provider::external::brokered_credential_address(scheme, scope, name); + let (outcome, error_kind) = match &result { + Ok(_) => (AuditOutcome::Written, None), + Err(error) => (AuditOutcome::Error, Some(error.kind())), + }; + let profile = self.resolve_profile_name(None); + self.record( + AuditAction::Set, + &profile, + outcome, + AuditFields { + key: Some(name), + command: Some("credential"), + provider_uri: Some("keyring://".into()), + reference: Some(&address), + error_kind, + ..Default::default() + }, + ); + result + } + /// Validates a spec's `credentials` (pure map lookups, no I/O): every name /// must be accepted by the target provider, every source must resolve to a /// known provider, and no source may itself declare credentials. Credential @@ -1509,10 +2091,13 @@ impl Secrets { return Ok(()); }; let resolved_target = self.resolve_provider_spec(spec.to_string()); - let supported = crate::provider::credential_names_for_spec(&resolved_target); + let supported = crate::provider::credential_names_for_spec(&resolved_target)?; + let dynamic = crate::provider::spec_uses_dynamic_credentials(&resolved_target)?; let provider_name = crate::provider::provider_display_name_for_spec(&resolved_target); for (name, source) in sorted_credential_entries(credentials) { - if !supported.contains(&name.as_str()) { + if dynamic { + validate_provider_credential_name(name)?; + } else if !supported.iter().any(|supported| supported == name) { let supported_display = if supported.is_empty() { "none".to_string() } else { @@ -1597,6 +2182,17 @@ impl Secrets { fields: AuditFields<'_>, ) { if let Some(logger) = &self.audit { + #[cfg(feature = "cli")] + let ipc_purpose = IPC_AUDIT_PURPOSE.with(|slot| slot.borrow().clone()); + #[cfg(feature = "cli")] + let purpose = ipc_purpose.as_ref().map(|purpose| AuditPurpose { + consumer: &purpose.consumer, + operation: &purpose.operation, + host: purpose.host.as_deref(), + path: purpose.path.as_deref(), + }); + #[cfg(not(feature = "cli"))] + let purpose: Option> = None; // Scopes affect only these bulk resolution surfaces. `get`, `set`, // and `import` deliberately ignore an ambient scope, so attaching it // to those events would falsely imply that it constrained the action. @@ -1624,8 +2220,10 @@ impl Secrets { reference: fields.reference.map(NativeAddress::render), outcome, error_kind: fields.error_kind, + interaction: fields.interaction, reason: self.reason.as_deref(), caller: self.caller.as_ref(), + purpose, }, ); } @@ -1646,9 +2244,9 @@ impl Secrets { reference: Option<&NativeAddress>, command: Option<&str>, ) { - let (outcome, error_kind) = match result { - Ok(()) => (AuditOutcome::Written, None), - Err(e) => (AuditOutcome::Error, Some(e.kind())), + let (outcome, error_kind, interaction) = match result { + Ok(()) => (AuditOutcome::Written, None, None), + Err(e) => (AuditOutcome::Error, Some(e.kind()), e.interaction()), }; self.record( AuditAction::Set, @@ -1660,6 +2258,7 @@ impl Secrets { provider_uri, reference, error_kind, + interaction, ..Default::default() }, ); @@ -1676,10 +2275,10 @@ impl Secrets { provider_uri: Option, reference: Option<&NativeAddress>, ) { - let (outcome, error_kind) = match result { - Ok(true) => (AuditOutcome::Deleted, None), - Ok(false) => (AuditOutcome::Missing, None), - Err(error) => (AuditOutcome::Error, Some(error.kind())), + let (outcome, error_kind, interaction) = match result { + Ok(true) => (AuditOutcome::Deleted, None, None), + Ok(false) => (AuditOutcome::Missing, None, None), + Err(error) => (AuditOutcome::Error, Some(error.kind()), error.interaction()), }; self.record( AuditAction::Delete, @@ -1690,6 +2289,7 @@ impl Secrets { provider_uri, reference, error_kind, + interaction, ..Default::default() }, ); @@ -1718,6 +2318,7 @@ impl Secrets { provider_uri, reference, error_kind: Some(err.kind()), + interaction: err.interaction(), ..Default::default() }, ); @@ -1989,7 +2590,7 @@ impl Secrets { /// Attach an audit logger (for testing which events an operation emits). #[cfg(test)] pub(crate) fn set_audit_for_test(&mut self, logger: crate::audit::AuditLogger) { - self.audit = Some(logger); + self.audit = Some(Arc::new(logger)); } /// Override the `require_reason` policy (for testing the gate without going @@ -2021,6 +2622,9 @@ impl Secrets { .map(|p| p.to_string()) .or_else(|| self.profile.clone()) .or_else(|| { + if self.ignore_ambient_profile { + return None; + } env::var("SECRETSPEC_PROFILE") .ok() .as_deref() @@ -2059,6 +2663,12 @@ impl Secrets { }) } + #[cfg(feature = "cli")] + pub(crate) fn validate_ipc_selection(&self) -> Result<()> { + let profile = self.resolve_profile_name(None); + Surface::Scoped.names(self, &profile).map(|_| ()) + } + /// The set of secret names the active scope admits, or `None` when no scope /// is active (meaning "no filtering — the whole profile participates"). /// @@ -2404,6 +3014,9 @@ impl Secrets { .map(|spec| spec.to_string()) .or_else(|| self.provider.clone()) .or_else(|| { + if self.ignore_ambient_provider { + return None; + } env::var("SECRETSPEC_PROVIDER") .ok() .as_deref() @@ -2423,7 +3036,7 @@ impl Secrets { group: &[&PlannedSecret], project: &str, profile: &str, - ) -> Result> { + ) -> Result> { let addresses = group .iter() .map(|planned| self.address_for_spec(planned, provider_spec, project, profile)) @@ -2433,7 +3046,7 @@ impl Secrets { .zip(&addresses) .map(|(planned, address)| (planned.name.as_str(), address.as_address())) .collect(); - provider.get_many(&requests) + provider.get_many_with_metadata(&requests) } /// Cache-first read for a whole plan: one provider per distinct cache store, @@ -2449,7 +3062,7 @@ impl Secrets { &self, plan: &ResolutionPlan, profile: &str, - ) -> HashMap { + ) -> HashMap)> { // Grouped by cache spec (not URI) so an alias's `credentials` stays // reachable at build time, and sorted so warnings come out in a stable // order. @@ -2497,8 +3110,14 @@ impl Secrets { .and_then(Route::cache) .expect("the group was built from secrets with a cached route"); match cached_entry(planned, cache, stored, &self.config.project.name, profile) { - CachedEntry::Fresh(value) => { - cached.insert(planned.name.clone(), (value, uri.clone())); + CachedEntry::Fresh { + value, + refresh_at_unix_ms, + } => { + cached.insert( + planned.name.clone(), + (value, uri.clone(), refresh_at_unix_ms), + ); } CachedEntry::Stale => { self.evict_cache_entry(provider.as_ref(), &planned.name, profile) @@ -2531,6 +3150,7 @@ impl Secrets { route: &Route, profile: &str, value: &SecretString, + expires_at_unix_ms: Option, ) { let Some(cache) = route.cache() else { return; @@ -2556,6 +3176,7 @@ impl Secrets { cache.max_age_secs, planned.cache_fingerprint(cache, &self.config.project.name, profile), value, + expires_at_unix_ms, ) { Ok(serialized) => serialized, Err(error) => { @@ -2768,7 +3389,7 @@ impl Secrets { value: &SecretString, ) { if route.cache().is_some() { - self.write_cached_secret(planned, route, profile, value); + self.write_cached_secret(planned, route, profile, value, None); return; } // Only re-plan when the declared routing could name a cached route at @@ -3063,7 +3684,7 @@ impl Secrets { project: &str, profile: &str, planned_primary_uri: Option<&str>, - ) -> Result<(Option, Option, Option)> { + ) -> FallbackReadResult { // If a provider chain is supplied, try it in order. if let Some(specs) = provider_specs { let mut last_error: Option = None; @@ -3129,7 +3750,7 @@ impl Secrets { last_uri = Some(provider_uri.clone()); let address = self.address_for_spec(planned, Some(spec), project, profile)?; last_reference = address.native().cloned(); - match provider.get(address.as_address()) { + match provider.get_with_metadata(address.as_address()) { Ok(Some(value)) => { return Ok((Some(value), Some(provider_uri), last_reference)); } @@ -3157,9 +3778,10 @@ impl Secrets { let backend = self.get_provider(None, Some(profile))?; let uri = backend.uri(); let address = self.address_for_spec(planned, None, project, profile)?; + let reference = address.native().cloned(); backend - .get(address.as_address()) - .map(|opt| (opt, Some(uri), address.native().cloned())) + .get_with_metadata(address.as_address()) + .map(|value| (value, Some(uri), reference)) } } @@ -3270,6 +3892,23 @@ impl Secrets { /// spec.set("DATABASE_URL", Some("postgres://localhost".to_string())).unwrap(); /// ``` pub fn set(&self, name: &str, value: Option) -> Result<()> { + let stored = self.store_secret(name, value)?; + eprintln!( + "{} Secret '{}' saved to {} (profile: {})", + "✓".green(), + name, + stored.provider_name, + stored.profile + ); + Ok(()) + } + + /// Shared core of [`Self::set`] and the resolver's `resolver.set`. + /// + /// Reports where the value landed rather than printing it: a resolver + /// session's only output is its framed response, and a confirmation line on + /// its stderr would be a diagnostic no consumer asked for. + fn store_secret(&self, name: &str, value: Option) -> Result { self.ensure_reason_for(AuditAction::Set, Some(name))?; // Check if the secret exists in the spec let profile_name = self.resolve_profile_name(None); @@ -3403,15 +4042,11 @@ impl Secrets { result?; self.sync_cache_after_write(&planned, route, &profile_name, stored_value); - eprintln!( - "{} Secret '{}' saved to {} (profile: {})", - "✓".green(), - name, - backend.name(), - profile_name - ); - - Ok(()) + Ok(StoredSecret { + profile: profile_name, + provider_name: backend.name().to_string(), + provider_uri: backend.uri(), + }) } /// Deletes one secret value from its authoritative provider. Available @@ -3423,6 +4058,12 @@ impl Secrets { /// also invalidates the manifest's cache so a later read cannot return the /// removed value. Missing values are an idempotent `Ok(false)`. pub fn delete(&self, name: &str) -> Result { + Ok(self.delete_secret(name)?.deleted) + } + + /// Shared core of [`Self::delete`] and the resolver's `resolver.delete`, + /// reporting the provider the removal was addressed to. + fn delete_secret(&self, name: &str) -> Result { self.ensure_reason_for(AuditAction::Delete, Some(name))?; let profile_name = self.resolve_profile_name(None); self.require_profile(&profile_name)?; @@ -3494,7 +4135,10 @@ impl Secrets { // Even a no-op authoritative delete must invalidate the cache: the // cache may still contain the only surviving copy of the value. self.sync_cache_after_delete(&planned, route, &profile_name); - Ok(deleted) + Ok(DeletedSecret { + deleted, + provider_uri: backend.uri(), + }) } /// Resolves one secret and prints it to stdout: the CLI's `secretspec get`. @@ -4086,11 +4730,7 @@ impl Secrets { }, ); - if delete_source - && !crate::provider::spec_provider_deletes( - &self.resolve_provider_spec(from_provider.to_string()), - ) - { + if delete_source && !from_provider_instance.supports_delete() { return Err(SecretSpecError::ProviderOperationFailed(format!( "provider '{}' does not support deleting secrets and cannot be used with import --delete-source", from_provider_instance.name() @@ -4522,15 +5162,22 @@ impl Secrets { let backend = self.write_provider_for_route(route, Some(profile_name))?; if backend.generated_value_persistence() == ProducedValuePersistence::Ephemeral { - eprintln!( - "{} {} - generated for this resolution without provider storage (profile: {})", - "✓".green(), - name, - profile_name - ); + if self.progress { + eprintln!( + "{} {} - generated for this resolution without provider storage (profile: {})", + "✓".green(), + name, + profile_name + ); + } return Ok(Some(value)); } + // Checked only on the persisting branch: an ephemeral provider returned + // above without touching a store, and refusing that would deny a read + // that never wrote anything. + self.ensure_produced_write_allowed(name)?; + // The provider states why a write is refused; wrapping it here would // only nest a second "Provider operation failed" prefix. backend.check_writable(addr)?; @@ -4558,13 +5205,15 @@ impl Secrets { stored_value, ); - eprintln!( - "{} {} - generated and saved to {} (profile: {})", - "✓".green(), - name, - backend.name(), - profile_name - ); + if self.progress { + eprintln!( + "{} {} - generated and saved to {} (profile: {})", + "✓".green(), + name, + backend.name(), + profile_name + ); + } Ok(Some(value)) } @@ -4574,9 +5223,14 @@ impl Secrets { /// input handle on Windows) when stdin is redirected, so the child retains /// its original stdin stream. Persistence is deliberately handled by /// [`Self::try_prompt_secret`], after this input-only step succeeds. - fn prompt_run_secret(&self, name: &str, profile: &str) -> Result { + fn prompt_run_secret( + &self, + name: &str, + profile: &str, + target_provider: Option<&str>, + ) -> Result { let value = if let Some(reader) = &self.prompt_reader { - reader(name, profile)? + reader(name, profile, target_provider)? } else { let message = format!("Enter value for {name} (profile: {profile}):"); let entered = inquire::Password::new(&message) @@ -4625,17 +5279,26 @@ impl Secrets { // and reject a read-only destination, before asking the operator for a // value. Ephemeral providers explicitly bypass the write path. if persistence == ProducedValuePersistence::Persist { + // Refused before anyone is asked: a person who answers a prompt + // whose answer is then thrown away has been asked for nothing. + self.ensure_produced_write_allowed(name)?; self.preflight_write(planned, profile_name, backend.as_ref())?; } - let value = self.prompt_run_secret(name, profile_name)?; + // Named only when the answer is actually going to be stored there, so a + // person is never shown a destination that will not receive it. + let target_provider = + (persistence == ProducedValuePersistence::Persist).then(|| backend.uri()); + let value = self.prompt_run_secret(name, profile_name, target_provider.as_deref())?; if persistence == ProducedValuePersistence::Ephemeral { - eprintln!( - "{} {} - entered for this run without provider storage (profile: {})", - "✓".green(), - name, - profile_name - ); + if self.progress { + eprintln!( + "{} {} - entered for this run without provider storage (profile: {})", + "✓".green(), + name, + profile_name + ); + } return Ok(value); } @@ -4653,13 +5316,15 @@ impl Secrets { set_result?; self.sync_cache_after_write(planned, route, profile_name, stored_value); - eprintln!( - "{} {} - entered and saved to {} (profile: {})", - "✓".green(), - name, - backend.name(), - profile_name - ); + if self.progress { + eprintln!( + "{} {} - entered and saved to {} (profile: {})", + "✓".green(), + name, + backend.name(), + profile_name + ); + } Ok(value) } @@ -4834,6 +5499,92 @@ impl Secrets { self.resolve_named_within(name, Surface::Scoped) } + /// Named resolution retaining every materialized file owner, as the + /// resolver sees it but without its caller attribution or prompting. + #[cfg(test)] + pub(crate) fn resolve_named_owned(&self, name: &str) -> Result { + self.resolve_named_owned_within(name, Surface::Scoped, Materialize::Values) + } + + /// Resolver-mode resolution with structured caller attribution scoped to the + /// blocking worker that performs the read (0.20+). + /// + /// `interactive` decides whether a `prompt = true` declaration with no + /// stored value may ask for one. It is true only when the session's client + /// advertised that it can reach a person; otherwise the declaration is left + /// to fail as unavailable rather than blocking on a question nobody will + /// see. + #[cfg(feature = "cli")] + pub(crate) fn resolve_named_owned_for_ipc( + &self, + name: &str, + purpose: IpcAuditPurpose, + interactive: bool, + ) -> Result { + let materialize = if interactive { + Materialize::Run + } else { + Materialize::Values + }; + with_ipc_audit_purpose(purpose, || { + self.resolve_named_owned_within(name, Surface::Scoped, materialize) + }) + } + + /// Resolver-mode write of one declared name (0.20+). + /// + /// The value goes where a resolver read of the same name would look for it, + /// so a consumer that stores and then resolves never has to model routing. + /// The session's scope bounds this exactly as it bounds a read: a name the + /// scope does not offer is not a name this session may write, which is why + /// the surface is checked here rather than left to the CLI's unscoped rule. + #[cfg(feature = "cli")] + pub(crate) fn store_named_for_ipc( + &self, + name: &str, + value: String, + purpose: IpcAuditPurpose, + ) -> Result { + with_ipc_audit_purpose(purpose, || { + self.require_ipc_surface(AuditAction::Set, name)?; + self.store_secret(name, Some(value)) + }) + } + + /// Resolver-mode removal of one declared name's stored value (0.20+), under + /// the same scope rule as [`Self::store_named_for_ipc`]. + #[cfg(feature = "cli")] + pub(crate) fn delete_named_for_ipc( + &self, + name: &str, + purpose: IpcAuditPurpose, + ) -> Result { + with_ipc_audit_purpose(purpose, || { + self.require_ipc_surface(AuditAction::Delete, name)?; + self.delete_secret(name) + }) + } + + /// Rejects a mutation of a name the session's scope does not offer, and + /// audits the attempt the way a read of a hidden name is audited. + #[cfg(feature = "cli")] + fn require_ipc_surface(&self, action: AuditAction, name: &str) -> Result<()> { + let profile_name = self.resolve_profile_name(None); + let visible = match Surface::Scoped.names(self, &profile_name) { + Ok(visible) => visible, + Err(err) => { + self.record_key_error(action, &profile_name, name, None, None, &err); + return Err(err); + } + }; + if visible.iter().any(|declared| declared == name) { + return Ok(()); + } + let err = SecretSpecError::SecretNotFound(name.to_string()); + self.record_key_error(action, &profile_name, name, None, None, &err); + Err(err) + } + /// Shared core of [`Self::resolve_named`] and [`Self::get`]. /// /// They differ only in which surface decides that a name exists: the SDK @@ -4841,6 +5592,16 @@ impl Secrets { /// `get` names one secret and has no `--scope`, so an ambient or configured /// scope must not hide a secret from it. fn resolve_named_within(&self, name: &str, surface: Surface) -> Result { + self.resolve_named_owned_within(name, surface, Materialize::Values)? + .into_embedded() + } + + fn resolve_named_owned_within( + &self, + name: &str, + surface: Surface, + materialize: Materialize, + ) -> Result { self.ensure_reason_for(AuditAction::Get, Some(name))?; let profile_name = self.resolve_profile_name(None); @@ -4859,7 +5620,7 @@ impl Secrets { // records an undefined secret). No provider can be attributed. let err = SecretSpecError::SecretNotFound(name.to_string()); self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err); - return Ok(NamedResolution::Undeclared); + return Ok(OwnedNamedResolution::Undeclared); } // The target plus its transitive composition inputs: the same @@ -4877,22 +5638,14 @@ impl Secrets { // would additionally enable the whole-profile constraint checks that a // single-secret read does not own. let mut read_addresses = HashMap::new(); - let outcome = - match self.execute_plan(&plan, Materialize::Values, None, Some(&mut read_addresses)) { - Ok(outcome) => outcome, - Err(err) => { - let reference = read_addresses.get(name); - self.record_key_error( - AuditAction::Get, - &profile_name, - name, - None, - reference, - &err, - ); - return Err(err); - } - }; + let outcome = match self.execute_plan(&plan, materialize, None, Some(&mut read_addresses)) { + Ok(outcome) => outcome, + Err(err) => { + let reference = read_addresses.get(name); + self.record_key_error(AuditAction::Get, &profile_name, name, None, reference, &err); + return Err(err); + } + }; // Exactly the coordinates the read reached, never the declared ones: an // alias `ref` template resolves to a different address per provider, and // a read served from cache addressed no authoritative store at all. @@ -4919,14 +5672,11 @@ impl Secrets { ..Default::default() }, ); - return Ok(NamedResolution::Missing { + return Ok(OwnedNamedResolution::Missing { required: entry.required, }); } - // Persist as_path temp files so the returned path stays valid - // for the caller, exactly as `resolve` does. - validated.keep_temp_files()?; let raw = validated .resolved .secrets @@ -4934,12 +5684,6 @@ impl Secrets { .expect("a Resolved entry always has a value") .expose_secret() .to_string(); - let (value, path) = if entry.as_path { - (None, Some(raw)) - } else { - (Some(raw), None) - }; - self.record( AuditAction::Get, &profile_name, @@ -4955,13 +5699,45 @@ impl Secrets { ..Default::default() }, ); - Ok(NamedResolution::Resolved(ResolvedSecret { - value, - path, - as_path: entry.as_path, - source: resolved_source(&entry), - source_provider: entry.source_provider, - })) + let source = resolved_source(&entry); + let source_provider = entry.source_provider; + let expires_at_unix_ms = validated.secret_expiries.remove(name); + let refresh_at_unix_ms = validated.refreshes.remove(name); + let mut supporting_files = std::mem::take(&mut validated.temp_files); + if entry.as_path { + // Every resolution branch materializes an `as_path` value + // through `insert_resolved`, so the owner is expected to be + // present. This stays an error rather than a panic because + // it runs inside the public SDK entry point and the resolver's + // blocking worker, where every other failure is recoverable. + let target = supporting_files + .iter() + .position(|file| file.path().to_string_lossy() == raw) + .ok_or_else(|| { + SecretSpecError::ProviderOperationFailed(format!( + "secret '{name}' is declared `as_path` but its resolved value has \ + no retained file owner" + )) + })?; + let file = supporting_files.swap_remove(target); + Ok(OwnedNamedResolution::File { + file, + source, + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + supporting_files, + }) + } else { + Ok(OwnedNamedResolution::Value { + value: raw, + source, + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + supporting_files, + }) + } } Err(errors) => { // Constraints are skipped for this partial plan, so a violation @@ -4997,7 +5773,7 @@ impl Secrets { ..Default::default() }, ); - Ok(NamedResolution::Missing { required }) + Ok(OwnedNamedResolution::Missing { required }) } } } @@ -5428,6 +6204,8 @@ impl Secrets { with_defaults: Vec::new(), resolution: Vec::new(), temp_files: Vec::new(), + secret_expiries: HashMap::new(), + refreshes: HashMap::new(), })); } @@ -5455,15 +6233,23 @@ impl Secrets { // a fallback chain are retried per-secret below, and secrets in the // failed group with no fallback surface the original error rather than // being reported as missing. - let mut fetched_values: HashMap = HashMap::new(); + let mut fetched_values: HashMap = HashMap::new(); let mut failed_primary_uris: HashMap, SecretSpecError> = HashMap::new(); let mut cached_uris: HashMap = HashMap::new(); + let mut known_secret_expiries: HashMap = HashMap::new(); + let mut known_refreshes: HashMap = HashMap::new(); // Consult caches before constructing source providers. Cache hits are // inserted into the same fetched-values map, and their names are // filtered out of source groups below. This ordering is what makes a // cached route useful when its remote provider is slow or unavailable. - for (name, (value, uri)) in self.read_cached_group(plan, profile) { + for (name, (value, uri, refresh_at)) in self.read_cached_group(plan, profile) { + if let Some(expires_at) = value.expires_at_unix_ms { + known_secret_expiries.insert(name.clone(), expires_at); + } + if let Some(refresh_at) = refresh_at { + known_refreshes.insert(name.clone(), refresh_at); + } cached_uris.insert(name.clone(), uri); fetched_values.insert(name, value); } @@ -5523,7 +6309,7 @@ impl Secrets { (provider_uri, group, provider): GroupFetch<'a>, project: &str, profile: &str, - ) -> (Option<&'a str>, Result>) { + ) -> (Option<&'a str>, Result>) { let result = secrets.fetch_group(&*provider, provider_uri, &group, project, profile); (provider_uri, result) } @@ -5628,23 +6414,36 @@ impl Secrets { let mut generated = false; match fetched_values.remove(name.as_str()) { - Some(value) => { + Some(provided) => { + let expires_at_unix_ms = provided.expires_at_unix_ms; + let value = provided.value; + if let Some(expires_at) = expires_at_unix_ms { + known_secret_expiries.insert(name.clone(), expires_at); + } let was_cached = cached_uris.contains_key(name); source_provider = cached_uris .remove(name) .or_else(|| group_uris.get(&primary_uri).cloned()); - if !was_cached && let Some(addresses) = read_addresses.as_deref_mut() { + if !was_cached + && let Ok(address) = + self.address_for_spec(planned, primary_uri, project, profile) + { // The primary answered, so it was addressed with the // coordinates the group fetch computed for this spec. - if let Ok(address) = - self.address_for_spec(planned, primary_uri, project, profile) + if let Some(addresses) = read_addresses.as_deref_mut() && let Some(native) = address.native() { addresses.insert(name.clone(), native.clone()); } } if !was_cached && materialize.values() { - self.write_cached_secret(planned, route, profile, &value); + self.write_cached_secret( + planned, + route, + profile, + &value, + expires_at_unix_ms, + ); } // Copy the value into the response only on a full pass; a // value-free pass has the status it needs and never @@ -5717,10 +6516,21 @@ impl Secrets { // what an audited failed read has to name. addresses.insert(name.clone(), reference); } - if let Some(value) = fallback_value { + if let Some(provided) = fallback_value { + let expires_at_unix_ms = provided.expires_at_unix_ms; + let value = provided.value; + if let Some(expires_at) = expires_at_unix_ms { + known_secret_expiries.insert(name.clone(), expires_at); + } source_provider = fallback_uri; if materialize.values() { - self.write_cached_secret(planned, route, profile, &value); + self.write_cached_secret( + planned, + route, + profile, + &value, + expires_at_unix_ms, + ); self.insert_resolved( &mut secrets, &mut temp_files, @@ -5876,6 +6686,22 @@ impl Secrets { statuses.get(dependency) == Some(&ResolutionStatus::Resolved) }); let status = if dependencies_resolved { + if let Some(expires_at) = template + .dependencies() + .iter() + .filter_map(|dependency| known_secret_expiries.get(dependency).copied()) + .min() + { + known_secret_expiries.insert(planned.name.clone(), expires_at); + } + if let Some(refresh_at) = template + .dependencies() + .iter() + .filter_map(|dependency| known_refreshes.get(dependency).copied()) + .min() + { + known_refreshes.insert(planned.name.clone(), refresh_at); + } if materialize.values() { let rendered = template .render(|dependency| { @@ -6049,6 +6875,8 @@ impl Secrets { with_defaults, resolution, temp_files, + secret_expiries: known_secret_expiries, + refreshes: known_refreshes, })) } } @@ -6300,6 +7128,19 @@ impl Secrets { } } +fn validate_provider_credential_name(name: &str) -> Result<()> { + let mut chars = name.chars(); + if name.len() > 256 + || !matches!(chars.next(), Some('a'..='z')) + || chars.any(|character| !matches!(character, 'a'..='z' | '0'..='9' | '_')) + { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "invalid provider credential name '{name}'" + ))); + } + Ok(()) +} + /// Output format for [`Secrets::export`] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "cli", derive(clap::ValueEnum))] @@ -6530,7 +7371,7 @@ mod write_target_tests { Ok("described".to_string()) } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "counting" } @@ -6891,6 +7732,103 @@ mod provider_credentials_cache_tests { } } +#[cfg(test)] +mod external_provider_credential_broker_tests { + use super::*; + use crate::provider::external::ProviderCredentialBroker; + + struct RecordingSource { + reads: Arc>>, + } + + impl ProviderTrait for RecordingSource { + fn convention_address( + &self, + _project: &str, + _profile: &str, + key: &str, + ) -> Result { + Ok(NativeAddress { + item: key.to_string(), + ..NativeAddress::default() + }) + } + + fn get(&self, address: Address<'_>) -> Result> { + let item = match address { + Address::Native(address) => address.item.clone(), + Address::Convention { key, .. } => key.to_string(), + }; + self.reads.lock().unwrap().push(item.clone()); + Ok(Some(SecretString::new(format!("value-for-{item}").into()))) + } + + fn set(&self, _address: Address<'_>, _value: &SecretString) -> Result<()> { + Ok(()) + } + + fn name(&self) -> &str { + "recording" + } + + fn uri(&self) -> String { + "recording://".into() + } + } + + #[test] + fn configured_external_credentials_are_read_only_when_requested_and_memoized() { + let reads = Arc::new(Mutex::new(Vec::new())); + let source: Arc = Arc::new(RecordingSource { + reads: reads.clone(), + }); + let configured = [("access_token", "token-a"), ("client_secret", "token-b")] + .into_iter() + .map(|(name, item)| { + ( + name.to_string(), + BrokerCredentialSource { + source: CredentialSource { + provider: "recording://".into(), + reference: Some(NativeAddress { + item: item.into(), + ..NativeAddress::default() + }), + }, + provider: source.clone(), + }, + ) + }) + .collect(); + let broker = SecretsProviderCredentialBroker { + alias: "remote".into(), + scheme: "example".into(), + project: "payments".into(), + profile: "production".into(), + configured, + fallback: Default::default(), + cache: Mutex::new(HashMap::new()), + audit: None, + reason: None, + caller: None, + #[cfg(feature = "cli")] + purpose: None, + }; + let request = secretspec_ipc::protocol::callback::CredentialParams { + name: "access_token".into(), + scope: "example://team-a".into(), + required: true, + }; + + let first = broker.get("example", &request).unwrap().unwrap(); + let second = broker.get("example", &request).unwrap().unwrap(); + + assert_eq!(first.expose_secret(), "value-for-token-a"); + assert_eq!(second.expose_secret(), "value-for-token-a"); + assert_eq!(reads.lock().unwrap().as_slice(), ["token-a"]); + } +} + #[cfg(test)] mod provider_cache_tests { use super::*; @@ -7301,7 +8239,7 @@ mod run_prompt_tests { let prompts = Arc::new(AtomicUsize::new(0)); let observed = Arc::clone(&prompts); let mut spec = prompted_spec(); - spec.set_prompt_reader(move |name, profile| { + spec.set_prompt_reader(move |name, profile, _| { assert_eq!(name, "DEPLOY_PASSWORD"); assert_eq!(profile, "default"); observed.fetch_add(1, Ordering::SeqCst); @@ -7336,7 +8274,7 @@ mod run_prompt_tests { let prompts = Arc::new(AtomicUsize::new(0)); let observed = Arc::clone(&prompts); let mut spec = prompted_dotenv_spec(&dotenv_path); - spec.set_prompt_reader(move |name, profile| { + spec.set_prompt_reader(move |name, profile, _| { assert_eq!(name, "DEPLOY_PASSWORD"); assert_eq!(profile, "default"); observed.fetch_add(1, Ordering::SeqCst); @@ -7365,7 +8303,9 @@ mod run_prompt_tests { fn run_surfaces_an_unavailable_controlling_terminal() { let _env = crate::tests::scrub_resolution_env(); let mut spec = prompted_spec(); - spec.set_prompt_reader(|name, _| Err(SecretSpecError::PromptUnavailable(name.to_string()))); + spec.set_prompt_reader(|name, _, _| { + Err(SecretSpecError::PromptUnavailable(name.to_string())) + }); let error = match spec.validate_audited(false, Materialize::Run) { Err(error) => error, @@ -7382,7 +8322,7 @@ mod run_prompt_tests { fn run_injects_the_prompted_value_into_the_child() { let _env = crate::tests::scrub_resolution_env(); let mut spec = prompted_spec(); - spec.set_prompt_reader(|_, _| Ok(SecretString::new("entered-once".into()))); + spec.set_prompt_reader(|_, _, _| Ok(SecretString::new("entered-once".into()))); let exit = spec .run_command(vec![ diff --git a/secretspec/src/serve.rs b/secretspec/src/serve.rs new file mode 100644 index 000000000..4434a57da --- /dev/null +++ b/secretspec/src/serve.rs @@ -0,0 +1,1222 @@ +use crate::resolve::ResolvedSource; +use crate::secrets::{IpcAuditPurpose, OwnedNamedResolution}; +use crate::{SecretSpecError, Secrets}; +use async_trait::async_trait; +use rand::RngCore; +use secrecy::SecretString; +use secretspec_ipc::RequestId; +use secretspec_ipc::error::{ErrorKind, RpcError}; +use secretspec_ipc::protocol::callback::{self, PromptParams}; +use secretspec_ipc::protocol::resolver::{ + CAPABILITIES, DeleteParams, DeleteResult, DeletedStatus, GetParams, GetResult, + InitializeApplication, InitializedApplication, MUTATION_CAPABILITIES, Manifest, MissingResult, + MissingStatus, PathRepresentation, ReleaseParams, ReleaseResult, Representation, + ResolvedPathResult, ResolvedStatus, ResolvedValueResult, SetParams, SetResult, Source, + StoredStatus, UndeclaredResult, UndeclaredStatus, ValueRepresentation, +}; +use secretspec_ipc::resolver::{ResolverHandler, serve_resolver}; +use secretspec_ipc::server::{RequestContext, RpcResult, ServerConfig}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::path::PathBuf; +use std::sync::Arc; +#[cfg(unix)] +use std::time::Duration; +use tempfile::{NamedTempFile, TempDir}; +use tokio::sync::Mutex; + +const MAX_SESSION_LEASES: usize = 1024; +const MAX_SESSION_SUPPORTING_FILES: usize = 4096; +#[cfg(unix)] +const STALE_SESSION_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +struct Lease { + path: PathBuf, + identity: same_file::Handle, +} + +struct ResolverState { + secrets: Arc, + session_dir: PathBuf, + leases: Mutex>, + pending_leases: Mutex>, + supporting_files: Mutex>, + pending_supporting_files: Mutex>>, + _session_dir_owner: TempDir, +} + +#[derive(Default)] +struct ResolverHandlerImpl { + state: Mutex>>, + /// Withholds the mutation capabilities, so the session can resolve secrets + /// but not change them. Off by default: a consumer that may read a store + /// can already run `secretspec set` against it, so refusing writes protects + /// nothing unless an operator deliberately launches the resolver this way. + read_only: bool, +} + +impl ResolverHandlerImpl { + fn new(read_only: bool) -> Self { + Self { + state: Mutex::new(None), + read_only, + } + } + + async fn state(&self) -> RpcResult> { + self.state + .lock() + .await + .clone() + .ok_or_else(|| RpcError::new(ErrorKind::Internal)) + } + + fn audit_purpose(purpose: secretspec_ipc::protocol::resolver::Purpose) -> IpcAuditPurpose { + IpcAuditPurpose { + consumer: purpose.consumer, + operation: purpose.operation, + host: purpose.host, + path: purpose.path, + } + } +} + +#[async_trait] +impl ResolverHandler for ResolverHandlerImpl { + async fn initialize( + &self, + _context: &RequestContext, + application: InitializeApplication, + ) -> RpcResult { + let manifest_kind = application.manifest.kind().to_string(); + let read_only = self.read_only; + let loaded = tokio::task::spawn_blocking(move || { + let mut secrets = match application.manifest { + Manifest::Path { path } => Secrets::load_from_ipc(PathBuf::from(path).as_path()), + Manifest::Inline { toml, base_dir } => { + Secrets::load_inline_ipc(&toml, PathBuf::from(base_dir).as_path()) + } + }?; + if let Some(provider) = application.provider { + secrets.set_provider(provider); + } + if let Some(profile) = application.profile { + secrets.set_profile(profile); + } + if let Some(scope) = application.scope { + secrets.set_scope(scope); + } + if let Some(reason) = application.reason { + secrets = secrets.with_reason(reason); + } + if let Some(duration_ms) = application.requested_authorization_duration_ms { + secrets = secrets.with_requested_authorization_duration( + std::time::Duration::from_millis(duration_ms), + ); + } + // The terminal reader would open /dev/tty, which in resolver mode + // belongs to whatever launched this process rather than to it. + // Every prompt goes back over the session instead, and reaches + // nothing at all unless the request being served has a channel. + secrets.set_prompt_reader(prompt_over_ipc); + secrets.silence_progress(); + if read_only { + // Withholding the mutation methods is not enough on its own: + // resolving a generatable or prompted name stores what it + // produced, so a read would still reach the store. + secrets.refuse_produced_writes(); + } + secrets.validate_ipc_selection()?; + cleanup_stale_session_dirs_once(); + let session_dir = tempfile::Builder::new() + .prefix("secretspec-ipc-") + .tempdir() + .map_err(SecretSpecError::Io)?; + harden_session_dir(&session_dir).map_err(SecretSpecError::Io)?; + mark_session_dir(&session_dir).map_err(SecretSpecError::Io)?; + Ok::<_, SecretSpecError>((secrets, session_dir)) + }) + .await + .map_err(|_| RpcError::new(ErrorKind::Internal))? + .map_err(map_resolver_error)?; + + let (secrets, session_dir) = loaded; + let state = Arc::new(ResolverState { + secrets: Arc::new(secrets), + session_dir: session_dir.path().to_path_buf(), + leases: Mutex::new(HashMap::new()), + pending_leases: Mutex::new(HashMap::new()), + supporting_files: Mutex::new(Vec::new()), + pending_supporting_files: Mutex::new(HashMap::new()), + _session_dir_owner: session_dir, + }); + let mut slot = self.state.lock().await; + if slot.is_some() { + return Err(RpcError::new(ErrorKind::Conflict)); + } + *slot = Some(state); + Ok(InitializedApplication { + manifest_kind, + supports_inline_manifest: true, + }) + } + + fn capabilities(&self) -> Vec { + CAPABILITIES + .iter() + .chain(if self.read_only { + [].iter() + } else { + MUTATION_CAPABILITIES.iter() + }) + .map(|method| (*method).to_string()) + .collect() + } + + async fn get(&self, context: RequestContext, params: GetParams) -> RpcResult { + let state = self.state().await?; + let name = params.name; + let purpose = Self::audit_purpose(params.purpose); + let secrets = state.secrets.clone(); + // A `prompt = true` declaration with no stored value can only be + // answered by a person, and this process has no terminal: its stdin and + // stdout are the protocol. Asking is therefore possible exactly when the + // client said it could answer. When it did not, resolution runs in the + // non-prompting mode so the declaration fails immediately instead of + // blocking on a question nobody would see. + let interactive = context.peer.supports(callback::method::PROMPT); + let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::channel::(1); + let resolve = tokio::task::spawn_blocking(move || { + with_prompt_channel(prompt_tx, || { + secrets.resolve_named_owned_for_ipc(&name, purpose, interactive) + }) + }); + tokio::pin!(resolve); + // The resolve holds this request's only in-flight slot, so its prompts + // are pumped here rather than by a session-wide task: a prompt then + // inherits exactly the deadline and cancellation of the read that + // raised it, and cannot be answered on behalf of some other request. + let resolved = loop { + tokio::select! { + finished = &mut resolve => break finished, + Some(request) = prompt_rx.recv() => { + let answer = ask(&context, &request).await; + let _ = request.answer.send(answer); + } + } + } + .map_err(|_| RpcError::new(ErrorKind::Internal))? + .map_err(map_resolver_error)?; + if context.cancellation.is_cancelled() { + return Err(RpcError::new(ErrorKind::Cancelled)); + } + + match resolved { + OwnedNamedResolution::Undeclared => Ok(GetResult::Undeclared(UndeclaredResult { + status: UndeclaredStatus::Undeclared, + })), + OwnedNamedResolution::Missing { required } => Ok(GetResult::Missing(MissingResult { + status: MissingStatus::Missing, + required, + })), + OwnedNamedResolution::Value { + value, + source, + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + supporting_files, + } => { + if params.representation == Representation::Path { + return Err(RpcError::new(ErrorKind::RepresentationMismatch)); + } + retain_pending_supporting_files(&state, context.request_id, supporting_files) + .await?; + Ok(GetResult::Value(ResolvedValueResult { + status: ResolvedStatus::Resolved, + representation: ValueRepresentation::Value, + value, + source: map_source(source), + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + })) + } + OwnedNamedResolution::File { + file, + source, + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + supporting_files, + } => { + if params.representation == Representation::Value { + return Err(RpcError::new(ErrorKind::RepresentationMismatch)); + } + let (path, persisted) = persist_lease_file(file, &state.session_dir)?; + let identity = match same_file::Handle::from_file(persisted) { + Ok(identity) => identity, + Err(_) => { + let _ = std::fs::remove_file(&path); + return Err(RpcError::new(ErrorKind::OperationFailed)); + } + }; + if context.cancellation.is_cancelled() { + drop(identity); + let _ = std::fs::remove_file(&path); + return Err(RpcError::new(ErrorKind::Cancelled)); + } + if let Err(error) = + retain_pending_supporting_files(&state, context.request_id, supporting_files) + .await + { + drop(identity); + let _ = std::fs::remove_file(&path); + return Err(error); + } + let mut leases = state.leases.lock().await; + if leases.len() >= MAX_SESSION_LEASES { + drop(leases); + drop(identity); + let _ = std::fs::remove_file(&path); + state + .pending_supporting_files + .lock() + .await + .remove(&context.request_id); + return Err(RpcError::unavailable(None)); + } + let lease_id = loop { + let candidate = random_token(); + if !leases.contains_key(&candidate) { + break candidate; + } + }; + leases.insert( + lease_id.clone(), + Lease { + path: path.clone(), + identity, + }, + ); + drop(leases); + state + .pending_leases + .lock() + .await + .insert(context.request_id, lease_id.clone()); + Ok(GetResult::Path(ResolvedPathResult { + status: ResolvedStatus::Resolved, + representation: PathRepresentation::Path, + path: path.to_string_lossy().into_owned(), + path_lease_id: lease_id, + source: map_source(source), + source_provider, + expires_at_unix_ms, + refresh_at_unix_ms, + })) + } + } + } + + async fn set(&self, _context: RequestContext, params: SetParams) -> RpcResult { + let state = self.state().await?; + let name = params.name; + let value = params.value; + let purpose = Self::audit_purpose(params.purpose); + let secrets = state.secrets.clone(); + // No cancellation check after the write, unlike `get`: the value is + // already in the store by then, and reporting `cancelled` would tell + // the consumer that nothing happened. A caller whose request was + // cancelled or timed out learns the outcome by resolving the name. + let stored = + tokio::task::spawn_blocking(move || secrets.store_named_for_ipc(&name, value, purpose)) + .await + .map_err(|_| RpcError::new(ErrorKind::Internal))? + .map_err(map_resolver_error)?; + Ok(SetResult { + status: StoredStatus::Stored, + target_provider: Some(stored.provider_uri), + }) + } + + async fn delete( + &self, + _context: RequestContext, + params: DeleteParams, + ) -> RpcResult { + let state = self.state().await?; + let name = params.name; + let purpose = Self::audit_purpose(params.purpose); + let secrets = state.secrets.clone(); + let removed = + tokio::task::spawn_blocking(move || secrets.delete_named_for_ipc(&name, purpose)) + .await + .map_err(|_| RpcError::new(ErrorKind::Internal))? + .map_err(map_resolver_error)?; + Ok(DeleteResult { + status: DeletedStatus::Deleted, + deleted: removed.deleted, + target_provider: Some(removed.provider_uri), + }) + } + + async fn release( + &self, + _context: RequestContext, + params: ReleaseParams, + ) -> RpcResult { + let state = self.state().await?; + let mut leases = state.leases.lock().await; + let mut unique = HashSet::new(); + let mut released = 0; + for lease_id in params.path_lease_ids { + if !unique.insert(lease_id.clone()) { + continue; + } + if let Some(lease) = leases.remove(&lease_id) { + remove_lease(lease); + released += 1; + } + } + Ok(ReleaseResult { released }) + } + + async fn request_finished(&self, request_id: RequestId, committed: bool) { + let Ok(state) = self.state().await else { + return; + }; + let lease_id = state.pending_leases.lock().await.remove(&request_id); + let supporting_files = state + .pending_supporting_files + .lock() + .await + .remove(&request_id) + .unwrap_or_default(); + if committed { + state.supporting_files.lock().await.extend(supporting_files); + return; + } + if let Some(lease_id) = lease_id + && let Some(lease) = state.leases.lock().await.remove(&lease_id) + { + remove_lease(lease); + } + } + + async fn shutdown(&self) { + let state = self.state.lock().await.take(); + if let Some(state) = state { + let leases = std::mem::take(&mut *state.leases.lock().await); + state.pending_leases.lock().await.clear(); + state.pending_supporting_files.lock().await.clear(); + for lease in leases.into_values() { + remove_lease(lease); + } + state.supporting_files.lock().await.clear(); + } + } +} + +/// Runs the stale-session sweep at most once per process. +/// +/// The sweep only ever removes directories older than a week, so repeating it +/// per session buys nothing while charging a full temp-directory scan to every +/// session's startup deadline. +fn cleanup_stale_session_dirs_once() { + static SWEPT: std::sync::Once = std::sync::Once::new(); + SWEPT.call_once(cleanup_stale_session_dirs); +} + +fn random_token() -> String { + let mut bytes = [0_u8; 16]; + rand::rng().fill_bytes(&mut bytes); + data_encoding::BASE64URL_NOPAD.encode(&bytes) +} + +fn persist_lease_file( + mut file: NamedTempFile, + session_dir: &std::path::Path, +) -> RpcResult<(PathBuf, File)> { + harden_lease_file(file.path()).map_err(|_| RpcError::new(ErrorKind::OperationFailed))?; + for _ in 0..8 { + let path = session_dir.join(random_token()); + match file.persist_noclobber(&path) { + Ok(persisted) => { + if harden_lease_file(&path).is_err() { + drop(persisted); + let _ = std::fs::remove_file(&path); + return Err(RpcError::new(ErrorKind::OperationFailed)); + } + return Ok((path, persisted)); + } + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + file = error.file; + } + Err(_) => return Err(RpcError::new(ErrorKind::OperationFailed)), + } + } + Err(RpcError::unavailable(None)) +} + +#[cfg(windows)] +fn harden_session_dir(directory: &TempDir) -> std::io::Result<()> { + crate::windows_security::make_path_private(directory.path()) +} + +#[cfg(not(windows))] +fn harden_session_dir(_: &TempDir) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn harden_lease_file(path: &std::path::Path) -> std::io::Result<()> { + crate::windows_security::make_path_private(path) +} + +#[cfg(not(windows))] +fn harden_lease_file(_: &std::path::Path) -> std::io::Result<()> { + Ok(()) +} + +async fn retain_pending_supporting_files( + state: &ResolverState, + request_id: RequestId, + files: Vec, +) -> RpcResult<()> { + if files.len() > MAX_SESSION_SUPPORTING_FILES { + return Err(RpcError::unavailable(None)); + } + let mut pending = state.pending_supporting_files.lock().await; + let retained = state.supporting_files.lock().await; + let pending_count = pending.values().map(Vec::len).sum::(); + if retained + .len() + .saturating_add(pending_count) + .saturating_add(files.len()) + > MAX_SESSION_SUPPORTING_FILES + { + return Err(RpcError::unavailable(None)); + } + drop(retained); + pending.insert(request_id, files); + Ok(()) +} + +fn remove_lease(lease: Lease) { + let current = same_file::Handle::from_path(&lease.path); + let same = current + .as_ref() + .is_ok_and(|current| current == &lease.identity); + drop(current); + drop(lease.identity); + if same { + let _ = std::fs::remove_file(lease.path); + } +} + +#[cfg(unix)] +fn mark_session_dir(directory: &TempDir) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let marker = directory.path().join(".owner"); + std::fs::write(&marker, std::process::id().to_string())?; + std::fs::set_permissions(marker, std::fs::Permissions::from_mode(0o400)) +} + +#[cfg(not(unix))] +fn mark_session_dir(_: &TempDir) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn cleanup_stale_session_dirs() { + use std::os::unix::fs::MetadataExt; + + unsafe extern "C" { + fn geteuid() -> u32; + fn kill(pid: i32, signal: i32) -> i32; + } + // SAFETY: `geteuid` has no arguments and no memory-safety preconditions. + let uid = unsafe { geteuid() }; + let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("secretspec-ipc-") { + continue; + } + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != uid + || metadata.mode() & 0o077 != 0 + || metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_none_or(|age| age < STALE_SESSION_AGE) + { + continue; + } + let marker = path.join(".owner"); + let marker_safe = std::fs::symlink_metadata(&marker).is_ok_and(|metadata| { + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.uid() == uid + && metadata.len() <= 32 + }); + let owner_pid = marker_safe + .then(|| std::fs::read_to_string(marker).ok()) + .flatten() + .and_then(|pid| pid.parse::().ok()) + .filter(|pid| *pid > 0); + if owner_pid.is_some_and(|pid| { + // SAFETY: signal zero performs existence/permission probing only. + unsafe { kill(pid, 0) == 0 } + }) { + continue; + } + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + let children = children + .take(MAX_SESSION_LEASES + 2) + .collect::, _>>(); + let Ok(children) = children else { continue }; + if children.len() > MAX_SESSION_LEASES + 1 + || children.iter().any(|child| { + std::fs::symlink_metadata(child.path()).map_or(true, |metadata| { + !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.uid() != uid + }) + }) + { + continue; + } + for child in children { + let _ = std::fs::remove_file(child.path()); + } + let _ = std::fs::remove_dir(path); + } +} + +#[cfg(not(unix))] +fn cleanup_stale_session_dirs() { + // Refuse cleanup on platforms where this build cannot prove ownership and + // ACL isolation. Normal session shutdown still removes its own TempDir. +} + +/// One `prompt = true` declaration waiting for a person, carried from the +/// blocking resolve to the async handler that owns the session's transport. +struct PromptRequest { + name: String, + profile: String, + target_provider: Option, + answer: tokio::sync::oneshot::Sender>, +} + +thread_local! { + /// Set for the duration of one blocking resolve. Scoping it to the worker + /// thread rather than to the shared `Secrets` is what keeps a prompt tied to + /// the request that raised it: `Secrets` is shared by every request on the + /// session and could not name which one is asking. + static PROMPT_CHANNEL: RefCell>> = + const { RefCell::new(None) }; +} + +struct PromptChannelGuard(Option>); + +impl Drop for PromptChannelGuard { + fn drop(&mut self) { + PROMPT_CHANNEL.with(|slot| { + slot.replace(self.0.take()); + }); + } +} + +fn with_prompt_channel( + sender: tokio::sync::mpsc::Sender, + operation: impl FnOnce() -> T, +) -> T { + let previous = PROMPT_CHANNEL.with(|slot| slot.replace(Some(sender))); + let _guard = PromptChannelGuard(previous); + operation() +} + +/// The `prompt = true` reader installed on every resolver-mode `Secrets`. +/// +/// Blocking here is correct and bounded: this runs on a blocking worker, and +/// the handler that answers is bound by the originating request's deadline and +/// cancellation, so a caller that goes away takes the wait with it. +fn prompt_over_ipc( + name: &str, + profile: &str, + target_provider: Option<&str>, +) -> Result { + let sender = PROMPT_CHANNEL.with(|slot| slot.borrow().clone()); + let Some(sender) = sender else { + return Err(SecretSpecError::PromptUnavailable(name.to_string())); + }; + let (answer_tx, answer_rx) = tokio::sync::oneshot::channel(); + let request = PromptRequest { + name: name.to_string(), + profile: profile.to_string(), + target_provider: target_provider.map(str::to_string), + answer: answer_tx, + }; + if sender.blocking_send(request).is_err() { + return Err(SecretSpecError::PromptUnavailable(name.to_string())); + } + match answer_rx.blocking_recv() { + Ok(Some(value)) => Ok(SecretString::new(value.into())), + Ok(None) | Err(_) => Err(SecretSpecError::PromptUnavailable(name.to_string())), + } +} + +/// Put one prompt to the client and return its answer, or `None` for every way +/// it can fail. The distinction between declined, cancelled, and expired is not +/// carried back: the read fails as unavailable either way, and the session's +/// own cancellation or deadline produces the terminal response the caller sees. +async fn ask(context: &RequestContext, request: &PromptRequest) -> Option { + let params = PromptParams { + name: request.name.clone(), + profile: request.profile.clone(), + target_provider: request.target_provider.clone(), + }; + secretspec_ipc::resolver::prompt(context, ¶ms) + .await + .ok() + .map(|result| result.value) +} + +fn map_source(source: ResolvedSource) -> Source { + match source { + ResolvedSource::Provider => Source::Provider, + ResolvedSource::Generated => Source::Generated, + ResolvedSource::Default => Source::Default, + ResolvedSource::Composed => Source::Composed, + } +} + +fn map_resolver_error(error: SecretSpecError) -> RpcError { + let (kind, interaction) = match error { + SecretSpecError::ProviderProtocol { kind, interaction } => (kind, interaction), + SecretSpecError::PromptUnavailable(_) | SecretSpecError::ReasonRequired => { + (ErrorKind::InteractionRequired, None) + } + // Not a provider refusal and not a missing value: this session was + // configured without the authority to store what the read would have + // produced, which is the caller's answer. + SecretSpecError::ProducedValueWriteRefused(_) => (ErrorKind::PermissionDenied, None), + _ => (ErrorKind::OperationFailed, None), + }; + if kind == ErrorKind::InteractionRequired { + RpcError::interaction_required(interaction) + } else { + RpcError::new(kind) + } +} + +pub(crate) async fn run_stdio(read_only: bool) -> secretspec_ipc::Result<()> { + serve_resolver( + tokio::io::stdin(), + tokio::io::stdout(), + ResolverHandlerImpl::new(read_only), + ServerConfig { + product: secretspec_ipc::Product { + name: "secretspec-resolver".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + ..ServerConfig::default() + }, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use secretspec_ipc::client::Client; + use secretspec_ipc::protocol::resolver::{Purpose, method}; + use secretspec_ipc::protocol::{InitializeParams, Limits, Product, RESOLVER_PROTOCOL}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn deadline() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + + 2_000 + } + + #[test] + fn provider_protocol_error_kind_reaches_the_client_boundary() { + for kind in [ + ErrorKind::InteractionRequired, + ErrorKind::PermissionDenied, + ErrorKind::Conflict, + ErrorKind::Unavailable, + ] { + let mapped = map_resolver_error(SecretSpecError::ProviderProtocol { + kind, + interaction: None, + }); + assert_eq!(mapped.data.kind, kind); + assert_eq!(mapped.message, kind.message()); + } + } + + #[test] + fn provider_interaction_reaches_the_client_boundary() { + let interaction = secretspec_ipc::InteractionReference::authorization( + "apr_7K3M", + Some(1_786_766_405_000), + ); + let mapped = map_resolver_error(SecretSpecError::ProviderProtocol { + kind: ErrorKind::InteractionRequired, + interaction: Some(interaction.clone()), + }); + assert_eq!(mapped.data.interaction, Some(interaction)); + } + + #[tokio::test] + async fn exact_resolution_and_file_leases_are_session_owned() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "TOKEN=inline-value\nCERT=file-value\n").unwrap(); + std::fs::write( + &manifest, + r#" +[project] +name = "ipc-test" +revision = "1.0" +require_reason = false + +[profiles.default] +TOKEN = { description = "token" } +CERT = { description = "certificate", as_path = true } +UNRELATED = { description = "must not fail named resolution", required = true } +"#, + ) + .unwrap(); + + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (client_read, client_write) = tokio::io::split(client_io); + let (server_read, server_write) = tokio::io::split(server_io); + let server = tokio::spawn(serve_resolver( + server_read, + server_write, + ResolverHandlerImpl::default(), + ServerConfig::default(), + )); + let initialize = InitializeParams { + protocol: RESOLVER_PROTOCOL.to_string(), + versions: vec![1], + client: Product { + name: "resolver-test".to_string(), + version: "1".to_string(), + }, + limits: Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + client_methods: Vec::new(), + application: InitializeApplication { + manifest: Manifest::Path { + path: manifest.to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".to_string()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }, + }; + let (raw, _initialized) = Client::connect::<_, _, _, InitializedApplication>( + client_read, + client_write, + initialize, + deadline(), + ) + .await + .unwrap(); + let client = raw; + let purpose = Purpose { + consumer: "test".to_string(), + operation: "resolve".to_string(), + host: None, + path: None, + }; + let value = client + .call( + method::GET, + &GetParams { + name: "TOKEN".to_string(), + representation: Representation::Value, + purpose: purpose.clone(), + }, + deadline(), + ) + .await + .unwrap(); + assert!(matches!( + value, + GetResult::Value(ResolvedValueResult { ref value, .. }) if value == "inline-value" + )); + + let file = client + .call( + method::GET, + &GetParams { + name: "CERT".to_string(), + representation: Representation::Path, + purpose, + }, + deadline(), + ) + .await + .unwrap(); + let GetResult::Path(file) = file else { + panic!("expected file result") + }; + assert_eq!(std::fs::read_to_string(&file.path).unwrap(), "file-value"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&file.path).unwrap().permissions().mode() & 0o777, + 0o400 + ); + } + let released: ReleaseResult = client + .call( + method::RELEASE, + &ReleaseParams { + path_lease_ids: vec![file.path_lease_id.clone(), file.path_lease_id], + }, + deadline(), + ) + .await + .unwrap(); + assert_eq!(released.released, 1); + assert!(!std::path::Path::new(&file.path).exists()); + client.close(deadline()).await.unwrap(); + server.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn uncommitted_file_response_drops_its_lease() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "CERT=file-value\n").unwrap(); + std::fs::write( + &manifest, + r#" +[project] +name = "ipc-test" +revision = "1.0" +require_reason = false + +[profiles.default] +CERT = { description = "certificate", as_path = true } +"#, + ) + .unwrap(); + + let handler = ResolverHandlerImpl::default(); + let initialize_context = RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(2), + cancellation: Default::default(), + peer: secretspec_ipc::server::Peer::detached(), + }; + handler + .initialize( + &initialize_context, + InitializeApplication { + manifest: Manifest::Path { + path: manifest.to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".into()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }, + ) + .await + .unwrap(); + let request_id = RequestId::new(2).unwrap(); + let result = handler + .get( + RequestContext { + request_id, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(2), + cancellation: Default::default(), + peer: secretspec_ipc::server::Peer::detached(), + }, + GetParams { + name: "CERT".into(), + representation: Representation::Path, + purpose: Purpose { + consumer: "test".into(), + operation: "resolve".into(), + host: None, + path: None, + }, + }, + ) + .await + .unwrap(); + let GetResult::Path(file) = result else { + panic!("expected file result") + }; + assert!(std::path::Path::new(&file.path).exists()); + handler.request_finished(request_id, false).await; + assert!(!std::path::Path::new(&file.path).exists()); + handler.shutdown().await; + } + + /// Manifest for the mutation tests: one writable name, plus a scope that + /// excludes it so the same session can be pointed at a name it may not + /// touch. + const MUTABLE_MANIFEST: &str = r#" +[project] +name = "ipc-test" +revision = "1.0" +require_reason = false + +[profiles.default] +TOKEN = { description = "token", required = false } +OTHER = { description = "another secret", required = false } + +[scopes.reader] +secrets = ["OTHER"] +"#; + + async fn initialized_handler( + manifest: &std::path::Path, + dotenv: &std::path::Path, + scope: Option<&str>, + read_only: bool, + ) -> ResolverHandlerImpl { + let handler = ResolverHandlerImpl::new(read_only); + handler + .initialize( + &RequestContext { + request_id: RequestId::new(1).unwrap(), + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(2), + cancellation: Default::default(), + peer: secretspec_ipc::server::Peer::detached(), + }, + InitializeApplication { + manifest: Manifest::Path { + path: manifest.to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".into()), + scope: scope.map(str::to_string), + reason: None, + requested_authorization_duration_ms: None, + }, + ) + .await + .unwrap(); + handler + } + + fn request(id: u64) -> RequestContext { + RequestContext { + request_id: RequestId::new(id).unwrap(), + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(2), + cancellation: Default::default(), + peer: secretspec_ipc::server::Peer::detached(), + } + } + + fn test_purpose() -> Purpose { + Purpose { + consumer: "test".into(), + operation: "store".into(), + host: None, + path: None, + } + } + + #[tokio::test] + async fn a_stored_value_is_where_the_same_session_resolves_it() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "").unwrap(); + std::fs::write(&manifest, MUTABLE_MANIFEST).unwrap(); + let handler = initialized_handler(&manifest, &dotenv, None, false).await; + + let stored = handler + .set( + request(2), + SetParams { + name: "TOKEN".into(), + value: "stored-value".into(), + purpose: test_purpose(), + }, + ) + .await + .unwrap(); + assert_eq!(stored.status, StoredStatus::Stored); + assert!(stored.target_provider.unwrap().starts_with("dotenv:")); + + let resolved = handler + .get( + request(3), + GetParams { + name: "TOKEN".into(), + representation: Representation::Value, + purpose: test_purpose(), + }, + ) + .await + .unwrap(); + assert!(matches!( + resolved, + GetResult::Value(ResolvedValueResult { ref value, .. }) if value == "stored-value" + )); + + let removed = handler + .delete( + request(4), + DeleteParams { + name: "TOKEN".into(), + purpose: test_purpose(), + }, + ) + .await + .unwrap(); + assert!(removed.deleted); + let resolved = handler + .get( + request(5), + GetParams { + name: "TOKEN".into(), + representation: Representation::Value, + purpose: test_purpose(), + }, + ) + .await + .unwrap(); + assert!(matches!(resolved, GetResult::Missing(_))); + + // Removing what is no longer stored reports `false` rather than failing. + let removed = handler + .delete( + request(6), + DeleteParams { + name: "TOKEN".into(), + purpose: test_purpose(), + }, + ) + .await + .unwrap(); + assert!(!removed.deleted); + handler.shutdown().await; + } + + #[tokio::test] + async fn a_scope_bounds_what_the_session_may_write() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "").unwrap(); + std::fs::write(&manifest, MUTABLE_MANIFEST).unwrap(); + let handler = initialized_handler(&manifest, &dotenv, Some("reader"), false).await; + + let refused = handler + .set( + request(2), + SetParams { + name: "TOKEN".into(), + value: "stored-value".into(), + purpose: test_purpose(), + }, + ) + .await + .unwrap_err(); + assert_eq!(refused.data.kind, ErrorKind::OperationFailed); + // The write was refused before the store was touched. + assert_eq!(std::fs::read_to_string(&dotenv).unwrap(), ""); + handler.shutdown().await; + } + + /// Withholding the mutation methods is not by itself read-only. Resolving + /// a generatable name mints the value and stores it, so a read reached the + /// provider through a session that advertised no way to write. + #[tokio::test] + async fn a_read_only_endpoint_refuses_a_read_that_would_store() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "").unwrap(); + std::fs::write( + &manifest, + r#" +[project] +name = "ipc-test" +revision = "1.0" +require_reason = false + +[profiles.default] +MINTED = { description = "minted", type = "password", generate = true } +"#, + ) + .unwrap(); + + let params = || GetParams { + name: "MINTED".into(), + representation: Representation::Auto, + purpose: test_purpose(), + }; + + let handler = initialized_handler(&manifest, &dotenv, None, true).await; + let error = handler.get(request(2), params()).await.unwrap_err(); + assert_eq!(error.data.kind, ErrorKind::PermissionDenied); + assert_eq!( + std::fs::read_to_string(&dotenv).unwrap(), + "", + "a read-only session wrote the value it produced" + ); + handler.shutdown().await; + + // The same read on a writable session is unchanged: it mints, stores, + // and resolves. + let handler = initialized_handler(&manifest, &dotenv, None, false).await; + let resolved = handler.get(request(3), params()).await.unwrap(); + assert!(matches!(resolved, GetResult::Value(_))); + assert!(std::fs::read_to_string(&dotenv).unwrap().contains("MINTED")); + handler.shutdown().await; + } + + #[tokio::test] + async fn a_read_only_endpoint_advertises_no_mutation() { + let handler = ResolverHandlerImpl::new(true); + assert_eq!( + handler.capabilities(), + vec![method::GET.to_string(), method::RELEASE.to_string()] + ); + assert!( + ResolverHandlerImpl::new(false) + .capabilities() + .contains(&method::SET.to_string()) + ); + } +} diff --git a/secretspec/src/tests.rs b/secretspec/src/tests.rs index 06928d96a..d1be81a99 100644 --- a/secretspec/src/tests.rs +++ b/secretspec/src/tests.rs @@ -342,6 +342,8 @@ fn test_validation_result_structure() { with_defaults: Vec::new(), resolution: Vec::new(), temp_files: Vec::new(), + secret_expiries: HashMap::new(), + refreshes: HashMap::new(), }; assert_eq!(valid_result.missing_optional.len(), 1); assert_eq!(valid_result.with_defaults.len(), 0); @@ -4284,6 +4286,7 @@ fn operation_scoped_provider_cache_applies_changed_session_context_on_later_reso let item = format!("{PROJECT}/default/{SECRET}"); crate::provider::tests::take_stateful_reason_reads(&item); crate::provider::tests::take_stateful_caller_reads(&item); + crate::provider::tests::take_stateful_authorization_duration_reads(&item); let store = crate::provider::provider_from_spec( "statefultest://", crate::provider::ProviderCredentials::new(), @@ -4298,17 +4301,21 @@ fn operation_scoped_provider_cache_applies_changed_session_context_on_later_reso let spec = stateful_fallback_spec(PROJECT, SECRET, &primary_file) .with_reason("first reason") + .with_requested_authorization_duration(std::time::Duration::from_secs(8 * 60 * 60)) .with_caller( crate::CallerContext::new("git") .with_operation("credential_get") .with_resource("github.com"), ); spec.validate().unwrap().expect("first resolution succeeds"); - let spec = spec.with_reason("second reason").with_caller( - crate::CallerContext::new("git") - .with_operation("credential_store") - .with_resource("github.com"), - ); + let spec = spec + .with_reason("second reason") + .with_requested_authorization_duration(std::time::Duration::from_secs(30 * 60)) + .with_caller( + crate::CallerContext::new("git") + .with_operation("credential_store") + .with_resource("github.com"), + ); spec.validate() .unwrap() .expect("second resolution succeeds"); @@ -4335,6 +4342,13 @@ fn operation_scoped_provider_cache_applies_changed_session_context_on_later_reso ), ] ); + assert_eq!( + crate::provider::tests::take_stateful_authorization_duration_reads(&item), + vec![ + Some(std::time::Duration::from_secs(8 * 60 * 60)), + Some(std::time::Duration::from_secs(30 * 60)), + ] + ); } /// When the primary provider in a chain errors (e.g. authentication failure), @@ -10094,6 +10108,48 @@ fn cached_route_hits_cache_refreshes_after_clear_and_survives_source_loss() { ); } +#[cfg(feature = "cli")] +#[test] +fn named_cached_resolution_reports_the_cache_envelopes_expiry() { + let _env = scrub_resolution_env(); + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source.env"); + let cache = temp.path().join("cache.env"); + fs::write(&source, "API_KEY=remote\n").unwrap(); + let secrets = cached_dotenv_secrets(&[&source], &cache, "8h"); + + let first = secrets.resolve_named_owned("API_KEY").unwrap(); + let crate::secrets::OwnedNamedResolution::Value { + expires_at_unix_ms, .. + } = first + else { + panic!("the authoritative read resolves an inline value"); + }; + assert_eq!( + expires_at_unix_ms, None, + "the legacy provider API does not report authoritative read expiry" + ); + + let (_, stored) = dotenv_values(&cache).into_iter().next().unwrap(); + let payload = stored + .strip_prefix(crate::cache::CACHE_ENVELOPE_MARKER) + .unwrap(); + let envelope: serde_json::Value = serde_json::from_str(payload).unwrap(); + let expected = envelope["expires_at"].as_u64().unwrap() * 1000; + + let second = secrets.resolve_named_owned("API_KEY").unwrap(); + let crate::secrets::OwnedNamedResolution::Value { + expires_at_unix_ms, + refresh_at_unix_ms, + .. + } = second + else { + panic!("the cache read resolves an inline value"); + }; + assert_eq!(expires_at_unix_ms, None); + assert_eq!(refresh_at_unix_ms, Some(expected)); +} + #[test] fn inline_cached_uri_reads_refreshes_and_clears_like_a_cached_route() { let _env = scrub_resolution_env(); diff --git a/secretspec/src/validation.rs b/secretspec/src/validation.rs index cbaff473f..7d748a385 100644 --- a/secretspec/src/validation.rs +++ b/secretspec/src/validation.rs @@ -27,6 +27,12 @@ pub struct ValidatedSecrets { /// cleaned up when dropped. #[doc(hidden)] pub(crate) temp_files: Vec, + /// Provider-reported absolute validity bound of the secret itself. + #[doc(hidden)] + pub(crate) secret_expiries: HashMap, + /// Absolute time after which the resolver will not serve its cached copy. + #[doc(hidden)] + pub(crate) refreshes: HashMap, } impl ValidatedSecrets { diff --git a/secretspec/src/windows_security.rs b/secretspec/src/windows_security.rs new file mode 100644 index 000000000..ec18d8557 --- /dev/null +++ b/secretspec/src/windows_security.rs @@ -0,0 +1,495 @@ +use std::ffi::c_void; +use std::io; +use std::mem::{size_of, size_of_val}; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::ptr::{addr_of, null_mut}; +use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ + ConvertStringSidToSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, SetNamedSecurityInfoW, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, AclSizeInformation, + AddAccessAllowedAceEx, CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, + GetAce, GetAclInformation, GetLengthSid, GetTokenInformation, INHERIT_ONLY_ACE, InitializeAcl, + IsValidSid, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER, TokenUser, + WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_ALL_ACCESS, FILE_APPEND_DATA, FILE_DELETE_CHILD, FILE_WRITE_ATTRIBUTES, + FILE_WRITE_DATA, FILE_WRITE_EA, WRITE_DAC, WRITE_OWNER, +}; +use windows_sys::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_ALLOWED_CALLBACK_ACE_TYPE, + ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_ALLOWED_COMPOUND_ACE_TYPE, + ACCESS_ALLOWED_OBJECT_ACE_TYPE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +const GENERIC_WRITE: u32 = 0x4000_0000; +const GENERIC_ALL: u32 = 0x1000_0000; +const FILE_MUTATING_RIGHTS: u32 = FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD + | DELETE + | WRITE_DAC + | WRITE_OWNER + | GENERIC_WRITE + | GENERIC_ALL; + +// FILE_WRITE_DATA and FILE_APPEND_DATA are FILE_ADD_FILE and +// FILE_ADD_SUBDIRECTORY on directories. Creating a new sibling cannot alter an +// existing canonical path, so directory ancestors only reject rights that can +// mutate or replace an existing component. +const DIRECTORY_MUTATING_RIGHTS: u32 = FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD + | DELETE + | WRITE_DAC + | WRITE_OWNER + | GENERIC_WRITE + | GENERIC_ALL; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AclObjectKind { + File, + Directory, +} + +impl AclObjectKind { + fn mutating_rights(self) -> u32 { + match self { + Self::File => FILE_MUTATING_RIGHTS, + Self::Directory => DIRECTORY_MUTATING_RIGHTS, + } + } +} + +pub(crate) fn path_acl_is_trusted( + path: &Path, + object_kind: AclObjectKind, + system_scope: bool, +) -> io::Result { + let path = wide_path(path); + let mut owner: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + // SAFETY: all output pointers are valid for the duration of the call and + // `path` is a terminated Windows path. The returned descriptor is released + // with `LocalFree` below. + let status = unsafe { + GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut descriptor, + ) + }; + if status != ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(status as i32)); + } + let _descriptor = LocalDescriptor(descriptor); + if owner.is_null() || dacl.is_null() { + return Ok(false); + } + + let sids = TrustedSids::load()?; + if !sids.is_trusted(owner, system_scope) { + return Ok(false); + } + + let mut information = ACL_SIZE_INFORMATION::default(); + // SAFETY: `dacl` belongs to the live security descriptor and the output + // buffer has exactly the declared structure size. + if unsafe { + GetAclInformation( + dacl, + &mut information as *mut _ as *mut c_void, + size_of_val(&information) as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + for index in 0..information.AceCount { + let mut raw_ace = null_mut(); + // SAFETY: the ACL was returned by Windows and `index` is below its + // reported ACE count. + if unsafe { GetAce(dacl, index, &mut raw_ace) } == 0 { + return Err(io::Error::last_os_error()); + } + let header = unsafe { &*(raw_ace as *const ACE_HEADER) }; + if u32::from(header.AceFlags) & INHERIT_ONLY_ACE != 0 { + continue; + } + match u32::from(header.AceType) { + ACCESS_ALLOWED_ACE_TYPE => { + if usize::from(header.AceSize) < size_of::() { + return Ok(false); + } + let ace = unsafe { &*(raw_ace as *const ACCESS_ALLOWED_ACE) }; + if ace.Mask & object_kind.mutating_rights() != 0 { + let sid = addr_of!(ace.SidStart) as PSID; + if unsafe { IsValidSid(sid) } == 0 || !sids.is_trusted(sid, system_scope) { + return Ok(false); + } + } + } + ACCESS_ALLOWED_OBJECT_ACE_TYPE + | ACCESS_ALLOWED_COMPOUND_ACE_TYPE + | ACCESS_ALLOWED_CALLBACK_ACE_TYPE + | ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE => { + // Conditional and object-specific allow ACEs require a full + // token access check to interpret. Refuse them rather than + // accidentally treating an untrusted write grant as safe. + return Ok(false); + } + _ => {} + } + } + Ok(true) +} + +pub fn make_path_private(path: &Path) -> io::Result<()> { + let sids = TrustedSids::load()?; + let principals = [sids.current(), sids.system(), sids.administrators()]; + set_path_dacl(path, &principals) +} + +fn set_path_dacl(path: &Path, principals: &[PSID]) -> io::Result<()> { + let entries = principals + .iter() + .copied() + .map(|sid| AclEntry { + flags: 0, + mask: FILE_ALL_ACCESS, + sid, + }) + .collect::>(); + set_path_dacl_entries(path, &entries) +} + +struct AclEntry { + flags: u32, + mask: u32, + sid: PSID, +} + +fn set_path_dacl_entries(path: &Path, entries: &[AclEntry]) -> io::Result<()> { + let acl_bytes = size_of::() + + entries + .iter() + .map(|entry| { + size_of::() - size_of::() + + unsafe { GetLengthSid(entry.sid) as usize } + }) + .sum::(); + let mut storage = vec![0_u32; acl_bytes.div_ceil(size_of::())]; + let acl = storage.as_mut_ptr() as *mut ACL; + // SAFETY: `storage` is aligned, writable, and at least `acl_bytes` long. + if unsafe { InitializeAcl(acl, acl_bytes as u32, ACL_REVISION) } == 0 { + return Err(io::Error::last_os_error()); + } + for entry in entries { + // SAFETY: the ACL was initialized with enough capacity for every ACE, + // and every SID comes from a validated Windows API. + if unsafe { AddAccessAllowedAceEx(acl, ACL_REVISION, entry.flags, entry.mask, entry.sid) } + == 0 + { + return Err(io::Error::last_os_error()); + } + } + + let path = wide_path(path); + // SAFETY: `path` and `acl` remain live for the call. Passing null owner, + // group, and SACL pointers updates only the protected DACL. + let status = unsafe { + SetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl, + null_mut(), + ) + }; + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } +} + +struct TrustedSids { + token_user: Vec, + system: Vec, + administrators: Vec, + trusted_installer: Vec, +} + +impl TrustedSids { + fn load() -> io::Result { + let mut token: HANDLE = null_mut(); + // SAFETY: `token` is a valid output pointer and the pseudo process + // handle is always valid in the current process. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = OwnedHandle(token); + let mut token_bytes = 0; + // The size-probe call is expected to fail while setting `token_bytes`. + unsafe { + GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut token_bytes); + } + if token_bytes < size_of::() as u32 { + return Err(io::Error::last_os_error()); + } + let mut token_user = vec![0_usize; (token_bytes as usize).div_ceil(size_of::())]; + // SAFETY: the buffer is aligned and at least `token_bytes` long. + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + token_user.as_mut_ptr() as *mut c_void, + token_bytes, + &mut token_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let current = unsafe { (*(token_user.as_ptr() as *const TOKEN_USER)).User.Sid }; + if unsafe { IsValidSid(current) } == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + token_user, + system: well_known_sid(WinLocalSystemSid)?, + administrators: well_known_sid(WinBuiltinAdministratorsSid)?, + trusted_installer: string_sid( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + )?, + }) + } + + fn current(&self) -> PSID { + unsafe { (*(self.token_user.as_ptr() as *const TOKEN_USER)).User.Sid } + } + + fn system(&self) -> PSID { + self.system.as_ptr() as PSID + } + + fn administrators(&self) -> PSID { + self.administrators.as_ptr() as PSID + } + + fn trusted_installer(&self) -> PSID { + self.trusted_installer.as_ptr() as PSID + } + + fn is_trusted(&self, sid: PSID, system_scope: bool) -> bool { + let equal = |candidate| unsafe { EqualSid(sid, candidate) != 0 }; + equal(self.system()) + || equal(self.administrators()) + || equal(self.trusted_installer()) + || (!system_scope && equal(self.current())) + } +} + +fn string_sid(value: &str) -> io::Result> { + let wide: Vec = value.encode_utf16().chain(Some(0)).collect(); + let mut allocated: PSID = null_mut(); + // SAFETY: `wide` is terminated and `allocated` is a valid output pointer. + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut allocated) } == 0 { + return Err(io::Error::last_os_error()); + } + let allocated = LocalSid(allocated); + let size = unsafe { GetLengthSid(allocated.0) }; + let mut sid = vec![0_u32; (size as usize).div_ceil(size_of::())]; + // SAFETY: the destination is at least `size` bytes and both SIDs are valid. + if unsafe { CopySid(size, sid.as_mut_ptr() as PSID, allocated.0) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(sid) +} + +fn well_known_sid(kind: i32) -> io::Result> { + let mut size = SECURITY_MAX_SID_SIZE; + let mut sid = vec![0_u32; (size as usize).div_ceil(size_of::())]; + // SAFETY: the output buffer is `SECURITY_MAX_SID_SIZE` bytes long. + if unsafe { CreateWellKnownSid(kind, null_mut(), sid.as_mut_ptr() as PSID, &mut size) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(sid) + } +} + +fn wide_path(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + CloseHandle(self.0); + } + } + } +} + +struct LocalDescriptor(PSECURITY_DESCRIPTOR); + +impl Drop for LocalDescriptor { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } +} + +struct LocalSid(PSID); + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use windows_sys::Win32::Security::{ + CONTAINER_INHERIT_ACE, INHERIT_ONLY_ACE, OBJECT_INHERIT_ACE, WinWorldSid, + }; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, + }; + + fn set_world_access(path: &Path, mask: u32, flags: u32) { + let sids = TrustedSids::load().unwrap(); + let world = well_known_sid(WinWorldSid).unwrap(); + let entries = [ + AclEntry { + flags: 0, + mask: FILE_ALL_ACCESS, + sid: sids.current(), + }, + AclEntry { + flags: 0, + mask: FILE_ALL_ACCESS, + sid: sids.system(), + }, + AclEntry { + flags: 0, + mask: FILE_ALL_ACCESS, + sid: sids.administrators(), + }, + AclEntry { + flags, + mask, + sid: world.as_ptr() as PSID, + }, + ]; + set_path_dacl_entries(path, &entries).unwrap(); + } + + #[test] + fn private_acl_is_accepted_for_the_current_user() { + let directory = tempfile::tempdir().unwrap(); + make_path_private(directory.path()).unwrap(); + assert!(path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap()); + } + + #[test] + fn writable_acl_for_an_untrusted_principal_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + set_world_access(directory.path(), FILE_ALL_ACCESS, 0); + assert!(!path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap()); + } + + #[test] + fn directory_add_subdirectory_does_not_grant_file_append_access() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("endpoint.exe"); + std::fs::write(&file, "endpoint").unwrap(); + set_world_access(directory.path(), FILE_ADD_SUBDIRECTORY, 0); + set_world_access(&file, FILE_APPEND_DATA, 0); + + assert!(path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap()); + assert!(!path_acl_is_trusted(&file, AclObjectKind::File, false).unwrap()); + } + + #[test] + fn directory_add_file_does_not_grant_file_write_data_access() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("endpoint.exe"); + std::fs::write(&file, "endpoint").unwrap(); + set_world_access(directory.path(), FILE_ADD_FILE, 0); + set_world_access(&file, FILE_WRITE_DATA, 0); + + assert!(path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap()); + assert!(!path_acl_is_trusted(&file, AclObjectKind::File, false).unwrap()); + } + + #[test] + fn directory_rights_that_can_alter_existing_components_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let rights = [ + ("delete child", FILE_DELETE_CHILD), + ("delete", DELETE), + ("write DACL", WRITE_DAC), + ("write owner", WRITE_OWNER), + ("write extended attributes", FILE_WRITE_EA), + ("write attributes", FILE_WRITE_ATTRIBUTES), + ("generic write", GENERIC_WRITE), + ("generic all", GENERIC_ALL), + ]; + + for (name, mask) in rights { + set_world_access(directory.path(), mask, 0); + assert!( + !path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap(), + "{name} was accepted" + ); + } + } + + #[test] + fn inherit_only_access_is_ignored_until_it_becomes_effective() { + let directory = tempfile::tempdir().unwrap(); + let modify = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; + set_world_access( + directory.path(), + modify, + CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE | INHERIT_ONLY_ACE, + ); + + assert!(path_acl_is_trusted(directory.path(), AclObjectKind::Directory, false).unwrap()); + + let child = directory.path().join("child"); + std::fs::create_dir(&child).unwrap(); + assert!(!path_acl_is_trusted(&child, AclObjectKind::Directory, false).unwrap()); + } +} diff --git a/secretspec/tests/ipc_resolver.rs b/secretspec/tests/ipc_resolver.rs new file mode 100644 index 000000000..b4bfa7be0 --- /dev/null +++ b/secretspec/tests/ipc_resolver.rs @@ -0,0 +1,614 @@ +#![cfg(feature = "cli")] + +use secretspec_ipc::client::Client; +use secretspec_ipc::error::RpcError; +use secretspec_ipc::lifecycle::{Environment, LaunchOptions, PromptResponder, ResolverSession}; +use secretspec_ipc::protocol::callback::{PromptParams, PromptResult}; +use secretspec_ipc::protocol::resolver::{ + DeleteParams, GetParams, GetResult, InitializeApplication, Manifest, Purpose, ReleaseParams, + Representation, SetParams, method, +}; +use secretspec_ipc::protocol::{InitializeParams, Limits, Product}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +fn deadline(after: Duration) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() + .saturating_add(after.as_millis()) as u64 +} + +fn launch_options() -> LaunchOptions { + LaunchOptions { + executable: PathBuf::from(env!("CARGO_BIN_EXE_secretspec")), + arguments: vec![OsString::from("serve")], + environment: Environment::Inherit(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 64 * 1024, + } +} + +fn product() -> Product { + Product { + name: "integration-test".into(), + version: "1".into(), + } +} + +fn limits() -> Limits { + Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + } +} + +/// The events a case demands. Comparing the observed set against exactly this +/// is what makes a case-driven test fail when it silently stops exercising a +/// branch the case still claims to cover. +fn required_events(case: &Value) -> BTreeSet<&str> { + case["required_events"] + .as_array() + .unwrap() + .iter() + .map(|event| event.as_str().unwrap()) + .collect() +} + +#[tokio::test] +async fn checked_in_resolver_case_runs_against_the_real_cli() { + let case: Value = serde_json::from_str(include_str!( + "../../conformance/ipc/cases/resolver-leases.json" + )) + .unwrap(); + assert_eq!(case["schema_version"], 1); + assert_eq!(case["id"], "resolver.path-leases"); + assert!( + case["targets"] + .as_array() + .unwrap() + .iter() + .any(|target| target == "resolver") + ); + let actions = case["actions"].as_array().unwrap(); + let initialize_action = actions + .iter() + .find(|action| action["kind"] == "initialize") + .unwrap(); + assert_eq!(initialize_action["manifest"], "inline"); + assert_eq!(initialize_action["profile"], "default"); + + let directory = tempfile::tempdir().unwrap(); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "TOKEN=inline-value\nCERT=leased-value\n").unwrap(); + let manifest = r#" +[project] +name = "black-box-ipc" +revision = "1.0" +require_reason = false + +[profiles.default] +TOKEN = { description = "token" } +CERT = { description = "certificate", as_path = true } +OPTIONAL = { description = "optional", required = false } +UNRELATED = { description = "named resolution must not read this", required = true } +"#; + + let application = InitializeApplication { + manifest: Manifest::Inline { + toml: manifest.into(), + base_dir: directory.path().to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".into()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }; + let session = ResolverSession::launch( + LaunchOptions { + executable: PathBuf::from(env!("CARGO_BIN_EXE_secretspec")), + arguments: vec![OsString::from("serve")], + environment: Environment::Inherit(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 64 * 1024, + }, + Product { + name: "integration-test".into(), + version: "1".into(), + }, + Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + application, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + let purpose = Purpose { + consumer: "integration-test".into(), + operation: "resolve".into(), + host: None, + path: None, + }; + let mut events = BTreeSet::from(["initialized"]); + let mut active_lease: Option<(String, String)> = None; + + for action in actions.iter().skip(1) { + match action["kind"].as_str().unwrap() { + "resolve" => { + let name = action["name"].as_str().unwrap(); + let representation = match action["representation"].as_str().unwrap() { + "auto" => Representation::Auto, + "value" => Representation::Value, + "path" => Representation::Path, + other => panic!("unsupported representation {other}"), + }; + let result = session + .get( + &GetParams { + name: name.into(), + representation, + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + match (name, result) { + ("TOKEN", GetResult::Value(value)) => { + assert_eq!(value.value, "inline-value"); + assert_eq!(value.expires_at_unix_ms, None); + assert_eq!(value.refresh_at_unix_ms, None); + events.insert("resolved_value"); + } + ("OPTIONAL", GetResult::Missing(missing)) => { + assert!(!missing.required); + events.insert("missing"); + } + ("UNKNOWN", GetResult::Undeclared(_)) => { + events.insert("undeclared"); + } + ("CERT", GetResult::Path(leased)) => { + assert_eq!( + std::fs::read_to_string(&leased.path).unwrap(), + "leased-value" + ); + assert_eq!(leased.expires_at_unix_ms, None); + assert_eq!(leased.refresh_at_unix_ms, None); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&leased.path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o400 + ); + } + active_lease = Some((leased.path, leased.path_lease_id)); + events.insert("lease_created"); + } + _ => panic!("resolver returned the wrong result for {name}"), + } + } + "release" => { + assert_eq!(action["duplicates"], true); + let (path, lease_id) = active_lease.take().unwrap(); + let released = session + .release( + &ReleaseParams { + path_lease_ids: vec![lease_id.clone(), lease_id], + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert_eq!(released.released, 1); + assert!(!std::path::Path::new(&path).exists()); + events.insert("lease_removed"); + } + "disconnect" => { + let (path, _) = active_lease.take().unwrap(); + session + .close(deadline(Duration::from_secs(5))) + .await + .unwrap(); + assert!(!std::path::Path::new(&path).exists()); + events.insert("disconnect_cleanup"); + events.insert("closed"); + } + other => panic!("unsupported resolver case action {other}"), + } + } + + let required = case["required_events"] + .as_array() + .unwrap() + .iter() + .map(|event| event.as_str().unwrap()) + .collect::>(); + assert_eq!(events, required); +} + +/// The mutation methods against the real CLI, which is what a consumer such as +/// `cargo login` drives: a stored value must be exactly what the same session +/// then resolves, and removing it must be idempotent. +#[tokio::test] +async fn stored_values_round_trip_against_the_real_cli() { + let directory = tempfile::tempdir().unwrap(); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "").unwrap(); + let manifest = r#" +[project] +name = "cargo" +revision = "1.0" +require_reason = false + +[profiles.default] +CARGO_REGISTRY_TOKEN = { description = "Cargo registry token", required = false } +"#; + + let session = ResolverSession::launch( + LaunchOptions { + executable: PathBuf::from(env!("CARGO_BIN_EXE_secretspec")), + arguments: vec![OsString::from("serve")], + environment: Environment::Inherit(BTreeMap::new()), + allow_path_discovery: false, + max_stderr_bytes: 64 * 1024, + }, + Product { + name: "integration-test".into(), + version: "1".into(), + }, + Limits { + max_frame_bytes: 32 * 1024, + max_in_flight: 4, + }, + InitializeApplication { + manifest: Manifest::Inline { + toml: manifest.into(), + base_dir: directory.path().to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".into()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(session.supports(method::SET)); + assert!(session.supports(method::DELETE)); + + let purpose = Purpose { + consumer: "cargo".into(), + operation: "login".into(), + host: Some("crates.io".into()), + path: None, + }; + let stored = session + .set( + &SetParams { + name: "CARGO_REGISTRY_TOKEN".into(), + value: "stored-token".into(), + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(stored.target_provider.unwrap().starts_with("dotenv:")); + + let resolved = session + .get( + &GetParams { + name: "CARGO_REGISTRY_TOKEN".into(), + representation: Representation::Value, + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + let GetResult::Value(value) = resolved else { + panic!("expected the stored value") + }; + assert_eq!(value.value, "stored-token"); + + let removed = session + .delete( + &DeleteParams { + name: "CARGO_REGISTRY_TOKEN".into(), + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(removed.deleted); + let resolved = session + .get( + &GetParams { + name: "CARGO_REGISTRY_TOKEN".into(), + representation: Representation::Value, + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(matches!(resolved, GetResult::Missing(_))); + + // Removing what is no longer there is a success, not an error. + let removed = session + .delete( + &DeleteParams { + name: "CARGO_REGISTRY_TOKEN".into(), + purpose, + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(!removed.deleted); + session + .close(deadline(Duration::from_secs(5))) + .await + .unwrap(); +} + +/// The checked-in `resolver.prompt` case, against the real CLI. +/// +/// The resolver has no terminal of its own, so the question travels back to +/// this process and the answer travels forward. The second session in the case +/// advertises nothing and must resolve to a plain missing result without a +/// prompt ever being sent, which is what keeps a headless consumer from waiting +/// out its deadline. +#[tokio::test] +async fn checked_in_prompt_case_runs_against_the_real_cli() { + struct Responder { + asked: Arc>>, + } + + #[async_trait::async_trait] + impl PromptResponder for Responder { + async fn prompt(&self, params: PromptParams) -> Result { + self.asked.lock().unwrap().push(params); + Ok(PromptResult { + value: "typed-by-a-person".into(), + }) + } + } + + let case: Value = serde_json::from_str(include_str!( + "../../conformance/ipc/cases/resolver-prompt.json" + )) + .unwrap(); + assert_eq!(case["schema_version"], 1); + assert_eq!(case["id"], "resolver.prompt"); + let actions = case["actions"].as_array().unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let dotenv = directory.path().join("values.env"); + std::fs::write(&dotenv, "").unwrap(); + let manifest = r#" +[project] +name = "prompted" +revision = "1.0" +require_reason = false + +[profiles.default] +DEPLOY_PASSWORD = { description = "deploy password", prompt = true } +"#; + let application = || InitializeApplication { + manifest: Manifest::Inline { + toml: manifest.into(), + base_dir: directory.path().to_string_lossy().into_owned(), + }, + provider: Some(format!("dotenv:{}", dotenv.display())), + profile: Some("default".into()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }; + let purpose = Purpose { + consumer: "integration-test".into(), + operation: "resolve".into(), + host: None, + path: None, + }; + + let asked = Arc::new(Mutex::new(Vec::new())); + let mut events = BTreeSet::new(); + let mut session: Option = None; + + for action in actions { + match action["kind"].as_str().unwrap() { + "initialize" => { + let advertises = !action["client_methods"].as_array().unwrap().is_empty(); + let responder: Option> = advertises.then(|| { + Arc::new(Responder { + asked: asked.clone(), + }) as Arc + }); + session = Some( + ResolverSession::launch_with_prompt( + launch_options(), + product(), + limits(), + application(), + deadline(Duration::from_secs(5)), + responder, + ) + .await + .unwrap(), + ); + events.insert("initialized"); + } + "resolve" => { + let before = asked.lock().unwrap().len(); + let result = session + .as_ref() + .unwrap() + .get( + &GetParams { + name: action["name"].as_str().unwrap().into(), + representation: Representation::Value, + purpose: purpose.clone(), + }, + deadline(Duration::from_secs(10)), + ) + .await + .unwrap(); + let asked_now = asked.lock().unwrap().len(); + match action["expect"].as_str().unwrap() { + "prompted" => { + let GetResult::Value(value) = result else { + panic!("expected the answered value") + }; + assert_eq!(value.value, "typed-by-a-person"); + assert_eq!(asked_now, before + 1); + let params = asked.lock().unwrap().last().unwrap().clone(); + assert_eq!(params.name, "DEPLOY_PASSWORD"); + assert_eq!(params.profile, "default"); + // Named because the answer is stored there, and + // credential-free. + assert!(params.target_provider.unwrap().starts_with("dotenv:")); + events.insert("prompt_requested"); + events.insert("prompt_answered"); + } + "missing" => { + let GetResult::Missing(missing) = result else { + panic!("a prompt nobody can answer resolves to no value") + }; + assert!(missing.required); + assert_eq!(asked_now, before, "a headless session was still asked"); + events.insert("headless_missing"); + events.insert("no_prompt_requested"); + } + other => panic!("unsupported prompt expectation {other}"), + } + } + "disconnect" => { + session + .take() + .unwrap() + .close(deadline(Duration::from_secs(5))) + .await + .unwrap(); + // The answer was provisioned into the store, so it survives the + // session that obtained it. Cleared afterwards so the headless + // session starts from the same empty store. + if std::fs::read_to_string(&dotenv) + .unwrap() + .contains("typed-by-a-person") + { + events.insert("answer_persisted"); + std::fs::write(&dotenv, "").unwrap(); + } + events.insert("closed"); + } + other => panic!("unsupported prompt case action {other}"), + } + } + + assert_eq!(events, required_events(&case)); +} + +#[tokio::test] +async fn resolver_ephemeral_generation_is_silent_on_stderr() { + use tokio::io::AsyncReadExt; + + let directory = tempfile::tempdir().unwrap(); + let manifest = r#" +[project] +name = "silent-generation" +revision = "1.0" +require_reason = false + +[profiles.default] +SESSION_TOKEN = { description = "session token", type = "password", generate = true } +"#; + let application = InitializeApplication { + manifest: Manifest::Inline { + toml: manifest.into(), + base_dir: directory.path().to_string_lossy().into_owned(), + }, + provider: Some("null://".into()), + profile: Some("default".into()), + scope: None, + reason: None, + requested_authorization_duration_ms: None, + }; + + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_secretspec")) + .arg("serve") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let initialize = InitializeParams { + protocol: "secretspec.resolver".into(), + versions: vec![1], + client: product(), + limits: limits(), + client_methods: Vec::new(), + application, + }; + let (client, _): ( + Client, + secretspec_ipc::protocol::InitializeResult< + secretspec_ipc::protocol::resolver::InitializedApplication, + >, + ) = Client::connect(stdout, stdin, initialize, deadline(Duration::from_secs(5))) + .await + .unwrap(); + let result: GetResult = client + .call( + method::GET, + &GetParams { + name: "SESSION_TOKEN".into(), + representation: Representation::Value, + purpose: Purpose { + consumer: "integration-test".into(), + operation: "resolve".into(), + host: None, + path: None, + }, + }, + deadline(Duration::from_secs(5)), + ) + .await + .unwrap(); + assert!(matches!(result, GetResult::Value(_))); + client + .close(deadline(Duration::from_secs(5))) + .await + .unwrap(); + + let status = child.wait().await.unwrap(); + let mut diagnostics = Vec::new(); + stderr.read_to_end(&mut diagnostics).await.unwrap(); + assert!(status.success()); + assert!( + diagnostics.is_empty(), + "resolver leaked generation progress: {}", + String::from_utf8_lossy(&diagnostics) + ); +}