diff --git a/.github/workflows/ffi-build.yml b/.github/workflows/ffi-build.yml index 46d61e33..8562b1fa 100644 --- a/.github/workflows/ffi-build.yml +++ b/.github/workflows/ffi-build.yml @@ -27,6 +27,10 @@ jobs: build: name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} + # Needed by the tag-only "Publish cdylib to the release" step below, which + # attaches the library to the GitHub Release cargo-dist creates for the tag. + permissions: + contents: write strategy: fail-fast: false matrix: @@ -75,3 +79,28 @@ jobs: path: | target/${{ matrix.target }}/release/${{ matrix.artifact }} secretspec-ffi/include/secretspec.h + + # On a version tag, attach the cdylib (named for its target, with a sha256 + # sidecar) to the GitHub Release so the PHP SDK's `secretspec-install-lib` + # command can fetch the right one for a plain `composer require` install. + # cargo-dist's release.yml creates the release for this same tag, so wait + # for it to exist, then upload with --clobber (both workflows may retry). + - name: Publish cdylib to the release + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + ext="${{ matrix.artifact }}"; ext="${ext##*.}" + asset="secretspec-ffi-${{ matrix.target }}.${ext}" + cp "target/${{ matrix.target }}/release/${{ matrix.artifact }}" "$asset" + # sha256 sidecar (BSD shasum on macOS, GNU sha256sum on Linux; both here). + if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256" + else shasum -a 256 "$asset" > "$asset.sha256"; fi + # Wait for cargo-dist to create the release (concurrent workflow). + for _ in $(seq 1 30); do + gh release view "$tag" >/dev/null 2>&1 && break || sleep 20 + done + gh release upload "$tag" "$asset" "$asset.sha256" --clobber diff --git a/.github/workflows/php-ext.yml b/.github/workflows/php-ext.yml new file mode 100644 index 00000000..d596af3c --- /dev/null +++ b/.github/workflows/php-ext.yml @@ -0,0 +1,122 @@ +name: "PHP extension" + +# Builds the secretspec-php-native PHP extension (ext-php-rs) as a prebuilt +# shared object for each supported PHP minor x platform, smoke tests that it +# loads and registers its functions, and on a version tag attaches the binaries +# to the GitHub Release. +# +# DISTRIBUTION STATUS (see RELEASE.md): this builds and publishes prebuilt +# extension binaries. Installing them is documented in the PHP SDK docs +# (download + `extension=`, or `docker-php-ext-enable`, or build from source with +# cargo). A one-command PIE install is a follow-up: PIE builds non-Windows +# extensions from source via phpize, which does not apply to a Cargo/ext-php-rs +# extension, so the prebuilt-binary path here is what is validated. +# +# The SDK itself is exercised on every PR by the devenv-based sdks.yml (both the +# extension and the ext-ffi fallback); this workflow only builds the +# distributable extension binaries. + +on: + workflow_dispatch: + push: + tags: + - v** + pull_request: + paths: + - "secretspec-php/**" + - ".github/workflows/php-ext.yml" + +jobs: + build: + name: php-${{ matrix.php }}-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: write # attach binaries to the release (tag runs only) + strategy: + fail-fast: false + matrix: + # Full php x target cross-product: the `include` entries share the + # `target` key with the axis, so they merge in the runner (rather than + # creating standalone combinations). Non-thread-safe (NTS) is the default + # CLI/FPM build. 8.1 is omitted (end of life). Windows and ZTS builds are + # a follow-up (ext-php-rs on Windows needs the PHP SDK dev pack + rust-lld; + # Windows users can use the ext-ffi backend meanwhile). + php: ["8.2", "8.3", "8.4"] + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - aarch64-apple-darwin + include: + - { target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } + - { target: aarch64-unknown-linux-gnu, runner: ubuntu-24.04-arm } + - { target: aarch64-apple-darwin, runner: macos-latest } + + steps: + - uses: actions/checkout@v5 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + # php-config + headers, needed by ext-php-rs's build. + tools: none + env: + # Ensure the dev headers are available for the build. + phpts: nts + + - name: Install Linux build dependencies (clang for bindgen, dbus for keyring) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y llvm-dev libclang-dev clang libdbus-1-dev pkg-config + + - name: Install Rust (pinned by rust-toolchain.toml) + run: rustup toolchain install + + - name: Build the extension + # Each runner is the target's native arch, so a plain release build emits + # the target's binary (no cross-compilation). + run: cargo build --release -p secretspec-php-native + + - name: Stage the extension under its distribution name + id: stage + shell: bash + run: | + set -euo pipefail + case "$(uname -s)" in + Darwin) built="libsecretspec_php_native.dylib" ;; + *) built="libsecretspec_php_native.so" ;; + esac + # dlopen resolves by path, not suffix, so the macOS .dylib ships as .so. + asset="secretspec-php-native-${{ matrix.php }}-nts-${{ matrix.target }}.so" + cp "target/release/$built" "$asset" + echo "asset=$asset" >> "$GITHUB_OUTPUT" + + - name: Smoke test (load the extension, check it registers) + # Every row builds natively, so the runner can load what it built. + shell: bash + run: | + php -d extension="$PWD/${{ steps.stage.outputs.asset }}" -r ' + assert(function_exists("secretspec_native_resolve")); + assert(function_exists("secretspec_native_abi_version")); + echo secretspec_native_abi_version(), PHP_EOL; + ' + + - uses: actions/upload-artifact@v4 + with: + name: php-ext-${{ matrix.php }}-${{ matrix.target }} + path: ${{ steps.stage.outputs.asset }} + + - name: Publish extension to the release + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + asset="${{ steps.stage.outputs.asset }}" + if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256" + else shasum -a 256 "$asset" > "$asset.sha256"; fi + # cargo-dist's release.yml creates the release for this tag; wait for it. + for _ in $(seq 1 30); do + gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 && break || sleep 20 + done + gh release upload "${GITHUB_REF_NAME}" "$asset" "$asset.sha256" --clobber diff --git a/.github/workflows/sdks.yml b/.github/workflows/sdks.yml index a3b7ed4b..5bdbad6a 100644 --- a/.github/workflows/sdks.yml +++ b/.github/workflows/sdks.yml @@ -2,7 +2,7 @@ name: "SDKs" # Runs every language SDK's full test suite (unit + cross-language conformance + # the schema/quicktype codegen pipeline) against a freshly built cdylib, so the -# Python/Go/Ruby/Node/Haskell bindings cannot silently rot. +# Python/Go/Ruby/Node/Haskell/PHP bindings cannot silently rot. on: pull_request: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f2a56c2..ada30297 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,8 @@ jobs: - name: Install Rust run: rustup toolchain install stable --profile minimal - name: cargo test - run: cargo test --all + # 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 diff --git a/.gitignore b/.gitignore index 30462689..6b3bd515 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ target .DS_Store + +# PHP (Composer) — manifest at repo root, vendor-dir points into secretspec-php/ +/composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f5cd67..71e6beaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- PHP SDK (`cachix/secretspec`): resolve secrets from PHP, Laravel, and Symfony + over the same shared resolver as the other language SDKs. It ships as a native + PHP extension that embeds the resolver (works under PHP-FPM with no + `ffi.enable`, like `ext-redis`), with an `ext-ffi` fallback that dlopens the + library at runtime for CLI and local development. + ### Changed - A `ref` routed at a single store (an explicit `--provider`, a single-provider chain, or the default provider) is now checked up front, before any store is diff --git a/Cargo.lock b/Cargo.lock index 344a6a9a..7221f685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ dependencies = [ "base64ct", "blake2", "cpufeatures 0.2.17", - "password-hash", + "password-hash 0.6.0", "zeroize", ] @@ -134,7 +134,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -584,6 +584,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", + "which", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -702,7 +725,7 @@ dependencies = [ "hmac", "num-bigint", "num-traits", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.8.5", "rand_chacha 0.3.1", "rayon", @@ -752,7 +775,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -840,7 +863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4773816a659edc3db0fc5b9a90fc870adf67910ff106c7fe4f8ce2810c732037" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -885,6 +908,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -910,6 +939,57 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cbc" version = "0.1.2" @@ -937,6 +1017,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1036,6 +1125,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -1055,7 +1155,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1067,7 +1167,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1134,6 +1234,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "convert_case" version = "0.6.0" @@ -1206,6 +1312,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1243,7 +1358,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1291,7 +1406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1327,7 +1442,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", ] [[package]] @@ -1350,6 +1475,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1360,8 +1499,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -1373,8 +1512,19 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", ] [[package]] @@ -1385,7 +1535,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1396,7 +1546,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1422,7 +1572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn", + "syn 2.0.117", ] [[package]] @@ -1485,7 +1635,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1495,7 +1645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -1517,7 +1667,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", ] [[package]] @@ -1557,7 +1707,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1640,6 +1790,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -1650,6 +1809,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ext-php-rs" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ebc08b233139f41c0aa0c9005510a5988f5524e215eec8fd5f97a96fb3aac2" +dependencies = [ + "anyhow", + "bindgen", + "bitflags", + "cc", + "cfg-if", + "ext-php-rs-derive", + "native-tls", + "once_cell", + "parking_lot", + "skeptic", + "ureq", + "zip", +] + +[[package]] +name = "ext-php-rs-derive" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee71f9125d01946f305868b6269042c6a4dc061f68ad34e2a151ea478f5b3ee" +dependencies = [ + "anyhow", + "darling 0.14.4", + "ident_case", + "lazy_static", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1691,6 +1885,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1703,6 +1907,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1762,7 +1981,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2148,6 +2367,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "0.2.12" @@ -2615,7 +2843,7 @@ checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2674,7 +2902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2721,6 +2949,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2795,9 +3029,15 @@ checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2870,7 +3110,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2889,6 +3129,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2896,6 +3142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2940,7 +3187,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2955,7 +3202,7 @@ dependencies = [ "quote", "regex", "semver", - "syn", + "syn 2.0.117", ] [[package]] @@ -2967,6 +3214,33 @@ dependencies = [ "libloading", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3074,12 +3348,49 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -3131,6 +3442,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "password-hash" version = "0.6.0" @@ -3140,6 +3462,18 @@ dependencies = [ "phc", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", + "hmac", + "password-hash 0.4.2", + "sha2", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3149,6 +3483,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3191,7 +3531,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3290,7 +3630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -3312,7 +3652,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3324,6 +3664,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pyo3" version = "0.29.0" @@ -3366,7 +3717,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3378,7 +3729,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3392,7 +3743,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls 0.23.37", "socket2 0.6.3", "thiserror 2.0.18", @@ -3413,7 +3764,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls 0.23.37", "rustls-pki-types", "slab", @@ -3580,7 +3931,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3751,6 +4102,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3766,6 +4123,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3775,7 +4145,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3944,7 +4314,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -4028,7 +4398,7 @@ dependencies = [ "secretspec", "serde", "serde_json", - "syn", + "syn 2.0.117", "tempfile", "toml 0.9.12+spec-1.1.0", "trybuild", @@ -4063,6 +4433,14 @@ dependencies = [ "secretspec", ] +[[package]] +name = "secretspec-php-native" +version = "0.14.0" +dependencies = [ + "ext-php-rs", + "secretspec", +] + [[package]] name = "secretspec-py-native" version = "0.14.0" @@ -4112,6 +4490,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -4161,7 +4543,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4172,7 +4554,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4207,7 +4589,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4259,7 +4641,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4331,12 +4713,33 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.12" @@ -4391,6 +4794,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -4424,6 +4833,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -4452,7 +4872,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4476,7 +4896,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4495,7 +4915,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4535,7 +4955,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4546,7 +4966,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4638,7 +5058,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4856,7 +5276,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4910,7 +5330,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -4977,6 +5397,20 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.8" @@ -5046,7 +5480,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5174,7 +5608,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -5250,6 +5684,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "2.1.1" @@ -5315,7 +5761,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5326,7 +5772,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5629,7 +6075,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5645,7 +6091,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5718,7 +6164,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5739,7 +6185,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5759,7 +6205,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5780,7 +6226,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5819,7 +6265,27 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd", ] [[package]] @@ -5828,6 +6294,35 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zxcvbn" version = "3.1.0" diff --git a/Cargo.toml b/Cargo.toml index 782a5cce..e7a90a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "secretspec-derive", "secretspec-ffi", "secretspec-node", + "secretspec-php", "secretspec-py", "examples/derive", ] diff --git a/RELEASE.md b/RELEASE.md index 269fd58d..1c05965c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -78,6 +78,21 @@ No registry involved. `go get` reads the tag directly from git; `go-embed.yml` just attaches per-platform cdylibs to the GitHub Release for the optional self-contained build. +### Packagist (PHP) — not yet set up + +Packagist has no OIDC/Trusted-Publishing mechanism; it reads a git repo and its +root `composer.json` directly. This repo publishes the PHP package straight from +the monorepo — the manifest lives at the repository root (`/composer.json`, with +`vendor-dir` pointed into `secretspec-php/` and autoload sourcing +`secretspec-php/src/`), so no split/mirror repo is needed. One-time setup: + +1. Submit `https://github.com/cachix/secretspec` on + [packagist.org](https://packagist.org) as package `cachix/secretspec`. +2. Enable the GitHub auto-update hook (the Packagist GitHub app), so each + `vX.Y.Z` tag becomes a Composer version automatically. + +No CI workflow or token is involved — Packagist pulls from the tag on push. + ## Python (PyPI) — `python-wheels.yml` - **Build:** the Rust resolver is statically linked into a pyo3 extension @@ -159,3 +174,63 @@ self-contained, vendored build — not a module-proxy install). `napi pre-publish`). Authenticated via **npm Trusted Publishing** (OIDC); no token stored in CI. - **One-time setup:** already done — see "Before your first release" above. + +## PHP (Packagist + extension) — `php-ext.yml` + +The PHP SDK ships as two artifacts, because PHP delivers native code as an +*extension* (provisioned at the image/php.ini level, like `ext-redis`), not +through Composer. + +- **Client → Packagist.** The pure-PHP client (`cachix/secretspec`) is published + straight from the monorepo: the Composer manifest is the repository-root + `/composer.json` (autoload sources `secretspec-php/src/`; `vendor-dir` points + into `secretspec-php/` so the tooling stays there), which Packagist reads + directly — no split/mirror repo, no CI, no token. Packagist auto-updates from + each `vX.Y.Z` tag. No version-sync is needed — Composer takes the version from + the git tag (like Go), so `sync-sdk-versions.sh` does not touch it. One-time + setup: see "Packagist (PHP)" above. +- **Extension → GitHub Release (`php-ext.yml`).** The `secretspec-php-native` + extension (an ext-php-rs cdylib embedding the resolver) is built as a prebuilt + shared object per PHP minor (8.2–8.4, NTS) × platform, smoke-tested, and + 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 + 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 + project, and a secrets tool should not silently fetch a binary during + `composer install`). +- **Gaps (follow-up, unvalidated cross-platform):** the extension matrix is + Linux + macOS, NTS-only (no ZTS), and links the runner's glibc/system libs + (same portability caveat as the Ruby/Python jobs — a baseline/manylinux build + is the fix). A **Windows** extension build is deferred (ext-php-rs on Windows + needs the PHP SDK dev pack + `rust-lld`; Windows users can use the ext-ffi + backend, whose cdylib `ffi-build.yml` does build for Windows). A one-command + PIE install is not wired (PIE builds non-Windows extensions from source via + phpize, which does not fit a Cargo extension); and the release-asset uploads + race cargo-dist's release creation, so they wait-then-`--clobber`. + +### First PHP release — checklist + +Everything through the CI is green, but the live Packagist + release-asset paths +can only be exercised for real once the package is registered and a tag exists. +In order: + +1. **Merge to `main`** so the repo-root `composer.json` is on the default branch. +2. **Register on Packagist** — the one-time "Packagist (PHP)" setup above. +3. **Smoke-test off `main` before tagging.** In a scratch project, confirm the + manifest resolves: + + ```bash + composer require cachix/secretspec:dev-main + ``` + +4. **Cut the `vX.Y.Z` tag.** Packagist ingests the tag as a version, and + `php-ext.yml` / `ffi-build.yml` attach the extension + cdylib binaries to the + GitHub Release. +5. **Verify against the live release** (the one path CI cannot cover): in a clean + project, `composer require cachix/secretspec`, then exercise **both** backends — + `vendor/bin/secretspec-install-lib` for the ext-ffi path, and a downloaded + `secretspec-php-native` `.so` (`extension=…`) for the extension path — and + confirm a resolve works under each. diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..28988e70 --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "cachix/secretspec", + "description": "PHP SDK for SecretSpec, a declarative secrets manager. A thin client over the shared Rust resolver; sources live in secretspec-php/.", + "type": "library", + "keywords": ["secrets", "secret", "configuration", "environment", "dotenv", "secretspec", "vault"], + "homepage": "https://secretspec.dev", + "license": "Apache-2.0", + "support": { + "issues": "https://github.com/cachix/secretspec/issues", + "source": "https://github.com/cachix/secretspec" + }, + "require": { + "php": ">=8.1", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0" + }, + "suggest": { + "ext-ffi": "Zero-install backend (set ffi.enable for web/FPM); run vendor/bin/secretspec-install-lib to fetch the native library", + "ext-secretspec-php-native": "Native extension that embeds the resolver; works under FPM with no ffi.enable (see https://secretspec.dev/sdk/php)" + }, + "autoload": { + "psr-4": { + "Secretspec\\": "secretspec-php/src/" + } + }, + "autoload-dev": { + "psr-4": { + "Secretspec\\Tests\\": "secretspec-php/tests/" + } + }, + "bin": [ + "secretspec-php/bin/secretspec-install-lib" + ], + "scripts": { + "test": "phpunit -c secretspec-php/phpunit.xml.dist", + "secretspec:install-lib": "@php secretspec-php/scripts/install-ffi-lib.php" + }, + "config": { + "sort-packages": true, + "vendor-dir": "secretspec-php/vendor" + } +} diff --git a/conformance/run.sh b/conformance/run.sh index 247e20b1..0df6988f 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -73,12 +73,20 @@ run_haskell() { ( for l in $SECRETSPEC_FFI_NATIVE_LIBS; do ghc_optl+=("--ghc-options=-optl$l"); done cabal test --extra-lib-dirs="$hs_lib_dir" "${ghc_optl[@]}" --test-show-details=streaming ); } +run_php() { ( + # The PHP SDK dlopens the cdylib via ext-ffi at runtime, located through + # SECRETSPEC_FFI_LIB exported above. The Composer manifest is at the repo root; + # vendor-dir points into secretspec-php/, so phpunit runs from there. + [ -d secretspec-php/vendor ] || composer install --no-interaction --no-progress >/dev/null + cd secretspec-php && ./vendor/bin/phpunit tests/ConformanceTest.php +); } run "Python" python run_python run "Go" go run_go run "Ruby" ruby run_ruby run "Node" node run_node run "Haskell" cabal run_haskell +run "PHP" php run_php echo echo "==> Conformance summary" diff --git a/devenv.nix b/devenv.nix index 9919b186..117f8171 100644 --- a/devenv.nix +++ b/devenv.nix @@ -39,6 +39,20 @@ languages.ruby.enable = true; # Haskell SDK (secretspec-hs) links the C ABI at build time via the FFI. languages.haskell.enable = true; + # 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. + # 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. + languages.php = { + enable = true; + extensions = [ "ffi" ]; + ini = '' + ffi.enable = true; + ''; + }; packages = [ # keyring @@ -47,6 +61,11 @@ pkgs.cargo-tarpaulin # installers pkgs.cargo-dist + # Building the secretspec-php-native extension (ext-php-rs) needs php-config + + # the PHP dev headers, and bindgenHook wires libclang/clang system headers so + # ext-php-rs's bindgen step can parse php.h. + pkgs.php.unwrapped.dev + pkgs.rustPlatform.bindgenHook ]; # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 8e016586..812378fe 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -198,6 +198,7 @@ Secrets can be stored in: keyring (default), dotenv files, environment variables { label: "Ruby SDK", slug: "sdk/ruby" }, { label: "Node.js SDK", slug: "sdk/nodejs" }, { label: "Haskell SDK", slug: "sdk/haskell" }, + { label: "PHP SDK", slug: "sdk/php" }, ], }, { diff --git a/docs/src/content/docs/sdk/overview.md b/docs/src/content/docs/sdk/overview.md index b0e53237..6b0ac64d 100644 --- a/docs/src/content/docs/sdk/overview.md +++ b/docs/src/content/docs/sdk/overview.md @@ -3,8 +3,8 @@ title: SDK Overview description: How the SecretSpec language SDKs work --- -SecretSpec ships SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, and -Haskell. They all resolve secrets from the same declarative `secretspec.toml`, +SecretSpec ships SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, +and PHP. They all resolve secrets from the same declarative `secretspec.toml`, and they all behave identically, because they share one resolver. ## One resolver, thin clients @@ -23,6 +23,9 @@ that core rather than a reimplementation: **Node.js/TypeScript** uses a [napi-rs](https://napi.rs/) native addon; both embed the same resolver directly and exchange the same JSON request/response shape as the C ABI. +- **PHP** prefers an [ext-php-rs](https://github.com/davidcole1340/ext-php-rs) + extension that embeds the resolver (working under FPM with no `ffi.enable`), + and falls back to loading the same C ABI at runtime through `ext-ffi`. Because resolution happens in one place, every provider, chain, profile, and generator works the same in every language, and a new provider added to the core @@ -47,7 +50,7 @@ print(resolved.secrets["DATABASE_URL"].get) See each language's page for the idiomatic spelling: [Rust](/sdk/rust), [Python](/sdk/python), [Go](/sdk/go), [Ruby](/sdk/ruby), -[Node.js](/sdk/nodejs), and [Haskell](/sdk/haskell). +[Node.js](/sdk/nodejs), [Haskell](/sdk/haskell), and [PHP](/sdk/php). ## Typed access @@ -76,7 +79,10 @@ no runtime library path to set: - **Go** embeds the `cdylib` in the module and loads it at runtime via purego (no cgo); an opt-in `-tags static` build links it statically instead. - **Node.js** builds the resolver into a napi-rs addon. +- **PHP** ships as a normal PHP extension (provisioned like `ext-redis`), with an + `ext-ffi` fallback that dlopens the bundled `cdylib`. -Because the resolver is linked or embedded directly, none of the SDKs depend on a +Because the resolver is linked or embedded directly, the SDKs do not depend on a separately installed `cdylib` or an `LD_LIBRARY_PATH`/`SECRETSPEC_FFI_LIB` -override at runtime. +override at runtime — the one exception being PHP's optional `ext-ffi` fallback, +where `SECRETSPEC_FFI_LIB` can point at a specific library build. diff --git a/docs/src/content/docs/sdk/php.md b/docs/src/content/docs/sdk/php.md new file mode 100644 index 00000000..dba0fb02 --- /dev/null +++ b/docs/src/content/docs/sdk/php.md @@ -0,0 +1,230 @@ +--- +title: PHP SDK +description: Resolve SecretSpec secrets from PHP, Laravel, and Symfony +--- + +The PHP SDK (`cachix/secretspec`) is a thin client over the same Rust resolver +every other SecretSpec SDK uses, so it inherits every provider, chain, profile, +and generator with no PHP-side logic. It reaches the resolver through one of two +native backends over an identical JSON contract: + +- **The `secretspec` PHP extension** (built with + [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 + to compile, ideal for CLI tools and local development; requires the FFI + extension enabled. + +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. + +## Install + +```bash +composer require cachix/secretspec +``` + +That installs the pure-PHP client. Then provide the native resolver with **one** +of the backends below. + +### Option A — the PHP extension (recommended for web / FPM) + +The `secretspec-php-native` extension embeds the resolver, so it works under +PHP-FPM with no `ffi.enable` and nothing to locate at runtime — the same +operational model as `ext-redis` or `ext-imagick` (the binary is provisioned at +the image/host level, not by Composer). Install it one of three ways: + +- **Prebuilt binary** — download the `secretspec-php-native--nts-` + shared object for your PHP version and platform from the + [GitHub release](https://github.com/cachix/secretspec/releases), then enable + it in `php.ini`: + + ```ini + extension=/path/to/secretspec-php-native.so + ``` + + In an official PHP Docker image, drop it into the extension dir and + `docker-php-ext-enable secretspec-php-native`. + +- **Build from source** (needs the Rust toolchain, `php-config`, and libclang): + + ```bash + cargo build --release -p secretspec-php-native + # then point extension= at target/release/libsecretspec_php_native.so + ``` + +Once loaded, `php -m` lists `secretspec-php-native` and the SDK uses it +automatically. + +### Option B — ext-ffi (quick start / CLI) + +The FFI backend dlopens the `secretspec-ffi` library at runtime. Enable the +bundled FFI extension — in CLI it is on by default; for the web SAPI set: + +```ini +extension=ffi +ffi.enable=true +``` + +Then fetch the native library for your platform (a one-time step; Composer does +not run it automatically): + +```bash +vendor/bin/secretspec-install-lib +``` + +That downloads the right `secretspec-ffi` 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. + +## Quick start + +```php +withProvider('keyring://') + ->withProfile('production') + ->withReason('boot web app') + ->load(); + +echo $resolved->provider, ' ', $resolved->profile, PHP_EOL; + +$db = $resolved->secrets['DATABASE_URL']; +echo $db->get(); // the value, or the file path for as_path secrets + +$resolved->setAsEnv(); // export everything into getenv()/$_ENV/$_SERVER +``` + +A missing required secret throws `Secretspec\MissingRequiredException` (with a +`->missing` list); any other failure throws `Secretspec\SecretSpecException` +(with a stable `->kind`). + +There is also a one-shot form using named arguments: + +```php +$resolved = SecretSpec::resolve(provider: 'keyring://', reason: 'boot'); +``` + +## Laravel + +Resolve your secrets early and export them so Laravel's `env()` and config see +them. A service provider is a natural home: + +```php +withProfile(app()->environment()) // "production", "local", ... + ->withReason('laravel boot') + ->load() + ->setAsEnv(); + } +} +``` + +Register it first in `bootstrap/providers.php` so the secrets are present before +other providers read configuration. Because `setAsEnv()` also populates `$_ENV` +and `$_SERVER`, the `env()` helper and any `config/*.php` that calls `env(...)` +resolve normally. + +> If you run `php artisan config:cache`, configuration is frozen at cache time +> and `env()` is not read per request. Either resolve before caching, or bind the +> `Resolved` into the container and read secrets from it directly where you need +> them. + +## Symfony + +Export the secrets in the front controller and `bin/console`, before the kernel +boots, so `%env(DATABASE_URL)%` in your config resolves: + +```php +// public/index.php (and bin/console) +use Secretspec\SecretSpec; + +require_once dirname(__DIR__).'/vendor/autoload.php'; + +SecretSpec::builder() + ->withProfile($_SERVER['APP_ENV'] ?? 'dev') + ->withReason('symfony boot') + ->load() + ->setAsEnv(); +``` + +`setAsEnv()` sets `$_ENV`, `$_SERVER`, and `putenv()`, all three of which +Symfony's env-var processors read, so no bundle or extra configuration is needed. + +## Plain PHP + +Point the builder at a specific manifest and provider and read the values back: + +```php +use Secretspec\SecretSpec; + +$resolved = SecretSpec::builder() + ->withPath(__DIR__.'/secretspec.toml') + ->withProvider('dotenv://.env.production') + ->withReason('cron job') + ->load(); + +foreach ($resolved->secrets as $name => $secret) { + // $secret->get() is the value, or a readable file path for as_path secrets. + printf("%s=%s\n", $name, $secret->get()); +} +``` + +## Typed access (codegen) + +Generate a typed class with `secretspec schema` plus +[quicktype](https://quicktype.io), then build it from `$resolved->fields()`: + +```bash +secretspec schema | quicktype -s schema --top-level SecretSpec --lang php -o SecretSpecTyped.php +``` + +```php +// $resolved->fields() is a [SECRET_NAME => value] map; quicktype's `from` +// wants an object, so cast it. +$typed = SecretSpec::from((object) $resolved->fields()); +echo $typed->getDatabaseURL(); +``` + +## Files (`as_path`) + +Secrets declared `as_path` are materialized to a temporary file and come back as +a readable path; `$secret->get()` returns the path. The SDK persists the file +(mode 0400) so the path stays valid after `load()` returns — you own its +lifetime. Call `$resolved->close()` when done to remove those temp files: + +```php +$resolved = SecretSpec::builder()->withReason('tls')->load(); +try { + $certPath = $resolved->secrets['TLS_CERT']->get(); + // ... use the file ... +} finally { + $resolved->close(); +} +``` + +## Native backends + +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 +`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 +conformance suite asserts it. diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index eb9ce1c0..6b3d0b23 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -509,7 +509,7 @@ $ secretspec audit -n 1 Language SDKs

Use it from any language.

- Rust, Python, Go, Ruby, Node.js/TypeScript, and Haskell all resolve from the same secretspec.toml through one Rust core — every provider, chain, and profile behaves identically, with no per-language logic. How the SDKs work → + Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, and PHP all resolve from the same secretspec.toml through one Rust core — every provider, chain, and profile behaves identically, with no per-language logic. How the SDKs work →

@@ -598,7 +598,7 @@ println!("{}"

- Every SDK is a thin client over one native core — the secretspec-ffi library (or a napi-rs addon for Node) — so a new provider is available everywhere at once. For typed access in the dynamic languages, secretspec schema feeds quicktype to generate typed classes. SDK overview → + Every SDK is a thin client over one native core — the secretspec-ffi library, or a native extension that embeds it for Python, Node, and PHP — so a new provider is available everywhere at once. For typed access in the dynamic languages, secretspec schema feeds quicktype to generate typed classes. SDK overview →

diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh index 73eb2357..3b765e90 100755 --- a/scripts/ci-sdks.sh +++ b/scripts/ci-sdks.sh @@ -85,4 +85,24 @@ echo "==> Haskell" --write-ghc-environment-files=always ) +echo "==> PHP" +# The PHP SDK has two native backends over the same resolver; exercise both. +# The Composer manifest is at the repo root (so Packagist can read it from the +# monorepo); vendor-dir points into secretspec-php/, so phpunit still runs there. +composer validate --no-check-lock --no-check-publish +composer install --no-interaction --no-progress + +echo "==> PHP (ext-ffi fallback, dlopens the cdylib via SECRETSPEC_FFI_LIB)" +( cd secretspec-php && php ./vendor/bin/phpunit ) + +echo "==> PHP (secretspec-php-native extension, ext-php-rs)" +# Build the extension in debug and load it directly; when it is present the SDK +# prefers it over ext-ffi. This also proves the extension registers its functions. +cargo build -p secretspec-php-native +case "$(uname -s)" in + Darwin) php_ext="$target_dir/debug/libsecretspec_php_native.dylib" ;; + *) php_ext="$target_dir/debug/libsecretspec_php_native.so" ;; +esac +( cd secretspec-php && php -d extension="$php_ext" ./vendor/bin/phpunit ) + echo "==> All SDK suites passed" diff --git a/secretspec-php/.gitignore b/secretspec-php/.gitignore new file mode 100644 index 00000000..d8d9cc84 --- /dev/null +++ b/secretspec-php/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +.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. +/lib/ diff --git a/secretspec-php/Cargo.toml b/secretspec-php/Cargo.toml new file mode 100644 index 00000000..435b6193 --- /dev/null +++ b/secretspec-php/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "secretspec-php-native" +version.workspace = true +edition.workspace = true +repository = "https://github.com/cachix/secretspec" +description = "ext-php-rs extension for the PHP SDK" +license = "Apache-2.0" +# The PHP client (PSR-4) lives in src/; keep the Rust source out of the way in +# native/ so the two toolchains do not fight over the same directory. +autobins = false +autoexamples = false +autotests = false +autobenches = false +# build.rs adds the macOS `-undefined dynamic_lookup` linker flag (Zend symbols +# resolve at dlopen time); no-op on Linux. +build = "build.rs" + +[lib] +name = "secretspec_php_native" +path = "native/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +ext-php-rs = "0.13" +secretspec = { workspace = true } diff --git a/secretspec-php/README.md b/secretspec-php/README.md new file mode 100644 index 00000000..aab7a26e --- /dev/null +++ b/secretspec-php/README.md @@ -0,0 +1,71 @@ +# SecretSpec PHP SDK + +A thin PHP client over the same Rust resolver every [SecretSpec](https://secretspec.dev) +SDK uses. Resolution — providers, fallback chains, profiles, generation, +`as_path` — happens in the core, so the SDK inherits every provider with no +PHP-side logic. + +It reaches the resolver through one of two native backends over an identical JSON +contract, preferring the first that is available: + +1. **The `secretspec` PHP extension** (built with + [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 + to compile; ideal for CLI and local development. + +## Install + +```bash +composer require cachix/secretspec +``` + +Then enable one backend: install the `secretspec-php-native` extension (a prebuilt +`.so` from the [releases](https://github.com/cachix/secretspec/releases), or built +from source), or enable FFI (`extension=ffi`, `ffi.enable=true`) and run +`vendor/bin/secretspec-install-lib` to fetch the native library. See the +[PHP SDK docs](https://secretspec.dev/sdk/php) for details, plus Laravel and +Symfony integration. + +## Usage + +```php +withProvider('keyring://') + ->withProfile('production') + ->withReason('boot web app') + ->load(); + +echo $resolved->secrets['DATABASE_URL']->get(); // value, or file path for as_path +$resolved->setAsEnv(); // export into getenv()/$_ENV/$_SERVER +``` + +A missing required secret throws `Secretspec\MissingRequiredException`; any other +failure throws `Secretspec\SecretSpecException` (with a stable `->kind`). + +## Development + +The SDK talks to the resolver built from this repository. The Composer manifest +lives at the repo root (so Packagist reads it from the monorepo); `vendor-dir` +points back here, so tests still run from `secretspec-php/`. From a `devenv shell`: + +```bash +composer install # run at the repo root; installs to secretspec-php/vendor + +# 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 +( cd secretspec-php && ./vendor/bin/phpunit ) + +# Backend 2: the native extension. Build and load it. +bash secretspec-php/scripts/build-ext.sh +( cd secretspec-php && php -d extension="$PWD/lib/secretspec.so" ./vendor/bin/phpunit ) +``` + +`tests/ConformanceTest.php` runs the shared cross-language conformance fixtures +in `../conformance/fixtures`. diff --git a/secretspec-php/bin/secretspec-install-lib b/secretspec-php/bin/secretspec-install-lib new file mode 100755 index 00000000..ab874eb9 --- /dev/null +++ b/secretspec-php/bin/secretspec-install-lib @@ -0,0 +1,7 @@ +#!/usr/bin/env php + String { + secretspec::resolve_json(request_json) +} + +/// The extension's version (tracks the crate version). Exposed to PHP as +/// `secretspec_native_abi_version()`. +#[php_function] +pub fn secretspec_native_abi_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module +} diff --git a/secretspec-php/phpunit.xml.dist b/secretspec-php/phpunit.xml.dist new file mode 100644 index 00000000..769e73ec --- /dev/null +++ b/secretspec-php/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/secretspec-php/scripts/build-ext.sh b/secretspec-php/scripts/build-ext.sh new file mode 100644 index 00000000..8bb2de98 --- /dev/null +++ b/secretspec-php/scripts/build-ext.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Build the secretspec-php-native extension (an ext-php-rs PHP extension that +# embeds the resolver) and stage it as lib/secretspec.so, ready to load with +# +# php -d extension="$(pwd)/lib/secretspec.so" ... +# +# or via `extension=` in php.ini. Set SECRETSPEC_PHP_PROFILE=debug for a faster +# unoptimized build (default: release). +set -euo pipefail + +pkg_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repo_root="$(cd "$pkg_dir/.." && pwd)" +profile="${SECRETSPEC_PHP_PROFILE:-release}" + +case "$(uname -s)" in + Darwin) built="libsecretspec_php_native.dylib"; staged="secretspec.so" ;; + MINGW*|MSYS*|CYGWIN*) built="secretspec_php_native.dll"; staged="secretspec.dll" ;; + *) built="libsecretspec_php_native.so"; staged="secretspec.so" ;; +esac + +build_flag=() +[ "$profile" = "release" ] && build_flag=(--release) +( cd "$repo_root" && cargo build "${build_flag[@]}" -p secretspec-php-native ) + +mkdir -p "$pkg_dir/lib" +# dlopen (which PHP uses to load extensions) resolves by path, not by suffix, so +# staging the macOS .dylib under a .so name loads fine. +cp -f "$repo_root/target/$profile/$built" "$pkg_dir/lib/$staged" +echo "built $pkg_dir/lib/$staged" diff --git a/secretspec-php/scripts/install-ffi-lib.php b/secretspec-php/scripts/install-ffi-lib.php new file mode 100644 index 00000000..2da4ea88 --- /dev/null +++ b/secretspec-php/scripts/install-ffi-lib.php @@ -0,0 +1,180 @@ +/cachix/secretspec/secretspec-php +] as $autoload) { + if (is_string($autoload) && is_file($autoload)) { + require_once $autoload; + break; + } +} + +/** Print a note and exit successfully — never fail the install over this. */ +function note(string $message): never +{ + fwrite(STDERR, "[secretspec] {$message}\n"); + exit(0); +} + +// Skip when the native extension is present: it embeds the resolver, so the +// ext-ffi cdylib is not needed. +if (function_exists('secretspec_native_resolve')) { + note('native extension detected; skipping ext-ffi library download.'); +} + +// Respect an explicit override. +$override = getenv('SECRETSPEC_FFI_LIB'); +if (is_string($override) && $override !== '') { + note('SECRETSPEC_FFI_LIB is set; skipping download.'); +} + +// Map the running platform to the release target triple; the library file name +// comes from Native so the download target and the loader agree on one name. +$target = secretspec_target(); +if ($target === null) { + note('no prebuilt secretspec-ffi library for this platform; set ' + . 'SECRETSPEC_FFI_LIB or install the secretspec extension.'); +} +if (!class_exists(\Secretspec\Native::class)) { + note('the Secretspec classes are not autoloadable; run `composer install` first.'); +} +$libName = \Secretspec\Native::libraryFileName(); + +$libDir = dirname(__DIR__) . '/lib'; +$dest = $libDir . '/' . $libName; +if (is_file($dest)) { + note("library already present at {$dest}."); +} + +$version = secretspec_installed_version(); +if ($version === null) { + note('could not determine the installed package version (dev checkout?); ' + . 'build the cdylib with `cargo build -p secretspec-ffi` instead.'); +} + +$asset = "secretspec-ffi-{$target}." . pathinfo($libName, PATHINFO_EXTENSION); +$base = "https://github.com/cachix/secretspec/releases/download/v{$version}"; +$url = "{$base}/{$asset}"; + +$bytes = secretspec_fetch($url); +if ($bytes === null) { + note("could not download {$url}; set SECRETSPEC_FFI_LIB or install the extension."); +} + +// Verify the sha256 sidecar when the release publishes one. +$sum = secretspec_fetch("{$url}.sha256"); +if (is_string($sum)) { + $expected = strtolower(trim(explode(' ', trim($sum))[0])); + $actual = hash('sha256', $bytes); + if ($expected !== '' && !hash_equals($expected, $actual)) { + note("checksum mismatch for {$asset} (expected {$expected}, got {$actual}); not installing."); + } +} + +if (!is_dir($libDir) && !@mkdir($libDir, 0o755, true) && !is_dir($libDir)) { + note("could not create {$libDir}."); +} +if (@file_put_contents($dest, $bytes) === false) { + note("could not write {$dest}."); +} +@chmod($dest, 0o644); + +fwrite(STDERR, "[secretspec] installed {$dest} ({$target}).\n"); +exit(0); + +/** + * The release target triple for the running platform, or null if no prebuilt + * secretspec-ffi library is published for it. + */ +function secretspec_target(): ?string +{ + $machine = strtolower(php_uname('m')); + $isArm = in_array($machine, ['arm64', 'aarch64'], true); + $isX64 = in_array($machine, ['x86_64', 'amd64'], true); + + switch (PHP_OS_FAMILY) { + case 'Linux': + if ($isX64) { + return 'x86_64-unknown-linux-gnu'; + } + if ($isArm) { + return 'aarch64-unknown-linux-gnu'; + } + break; + case 'Darwin': + if ($isArm) { + return 'aarch64-apple-darwin'; + } + break; + case 'Windows': + if ($isX64) { + return 'x86_64-pc-windows-msvc'; + } + break; + } + + return null; +} + +/** The installed version of this package, or null in a dev/path checkout. */ +function secretspec_installed_version(): ?string +{ + if (!class_exists(\Composer\InstalledVersions::class)) { + return null; + } + try { + $version = \Composer\InstalledVersions::getPrettyVersion('cachix/secretspec'); + } catch (\OutOfBoundsException) { + return null; + } + if ($version === null) { + return null; + } + // A tagged install reports "1.2.3"; dev branches report "dev-*", which has no + // matching release asset. + $version = ltrim($version, 'v'); + + return preg_match('/^\d+\.\d+\.\d+/', $version) === 1 ? $version : null; +} + +/** GET a URL, returning the body or null on any failure. */ +function secretspec_fetch(string $url): ?string +{ + // PHP's HTTP stream wrapper reads its options from the 'http' key for both + // http:// and https:// URLs, so a single entry covers both. + $context = stream_context_create([ + 'http' => ['method' => 'GET', 'follow_location' => 1, 'timeout' => 30, + 'header' => 'User-Agent: secretspec-php-installer'], + ]); + $body = @file_get_contents($url, false, $context); + + return $body === false ? null : $body; +} diff --git a/secretspec-php/src/Builder.php b/secretspec-php/src/Builder.php new file mode 100644 index 00000000..fa20d006 --- /dev/null +++ b/secretspec-php/src/Builder.php @@ -0,0 +1,173 @@ + */ + private array $request = []; + + /** Path to a `secretspec.toml`; omit to walk up from the working directory. */ + public function withPath(?string $path): self + { + return $this->set('path', $path); + } + + /** Provider address, e.g. `keyring://` or `dotenv://.env.production`. */ + public function withProvider(?string $provider): self + { + return $this->set('provider', $provider); + } + + /** Profile to resolve, e.g. `production`. */ + public function withProfile(?string $profile): self + { + return $this->set('profile', $profile); + } + + /** Human-readable reason for the access, surfaced to reason-policy providers. */ + public function withReason(?string $reason): self + { + return $this->set('reason', $reason); + } + + /** Set a request field when the value is provided; a no-op for null. */ + private function set(string $key, ?string $value): self + { + if ($value !== null) { + $this->request[$key] = $value; + } + + return $this; + } + + /** Omit secret values, returning only structure and provenance. */ + public function withNoValues(bool $noValues = true): self + { + $this->request['no_values'] = $noValues; + + return $this; + } + + /** + * Resolve the secrets. + * + * @throws MissingRequiredException if a required secret is missing + * @throws SecretSpecException for any other failure + */ + public function load(): Resolved + { + $response = $this->checkedResponse($this->request, 'resolve', self::RESOLVE_SCHEMA_VERSION); + + $missing = $response['missing_required'] ?? []; + if (!empty($missing)) { + throw new MissingRequiredException($missing); + } + + $secrets = []; + foreach ($response['secrets'] ?? [] as $name => $entry) { + $secrets[$name] = new ResolvedSecret( + $entry['value'] ?? null, + $entry['path'] ?? null, + $entry['as_path'] ?? false, + $entry['source'] ?? '', + $entry['source_provider'] ?? null, + ); + } + + return new Resolved( + $response['provider'], + $response['profile'], + $secrets, + $response['missing_optional'] ?? [], + ); + } + + /** + * Resolve a value-free {@see Report} (the inventory/preflight view, the same + * one the CLI exposes as `check --json`). Unlike {@see load()}, never throws + * {@see MissingRequiredException}: a missing required secret appears as a + * {@see SecretReport} with status `missing_required`. + * + * @throws SecretSpecException for a transport failure + */ + public function report(): Report + { + $request = $this->request; + $request['mode'] = 'report'; + $response = $this->checkedResponse($request, 'report', self::REPORT_SCHEMA_VERSION); + + $secrets = []; + foreach ($response['secrets'] ?? [] as $s) { + $secrets[] = new SecretReport( + $s['name'], + $s['status'], + $s['required'], + $s['source_provider'] ?? null, + $s['default_applied'], + $s['generated'], + $s['as_path'], + ); + } + + return new Report($response['provider'], $response['profile'], $secrets); + } + + /** + * Resolve a JSON request payload and return the validated `response` object, + * or throw. `$kind` is `resolve` or `report`; it selects the schema version + * to enforce and labels the version-mismatch message. + * + * @param array $request + * + * @return array + */ + private function checkedResponse(array $request, string $kind, int $expectedVersion): array + { + // An empty request must serialize as a JSON object `{}`, not an array + // `[]`; cast to object so the resolver parses it either way. + $payload = \json_encode((object) $request, \JSON_THROW_ON_ERROR); + $envelope = \json_decode(Native::resolve($payload), true, 512, \JSON_THROW_ON_ERROR); + + if (empty($envelope['ok'])) { + $err = $envelope['error'] ?? []; + throw new SecretSpecException($err['kind'] ?? 'unknown', $err['message'] ?? ''); + } + + $response = $envelope['response'] ?? null; + if ($response === null) { + throw new SecretSpecException('ffi', 'secretspec_resolve reported ok with no response'); + } + + $version = $response['schema_version'] ?? null; + if ($version !== $expectedVersion) { + throw new SecretSpecException( + 'version', + "unsupported {$kind} schema version " . \var_export($version, true) + . " (expected {$expectedVersion}); the secretspec-ffi library and this SDK " + . 'are out of sync', + ); + } + + return $response; + } +} diff --git a/secretspec-php/src/MissingRequiredException.php b/secretspec-php/src/MissingRequiredException.php new file mode 100644 index 00000000..8cb80971 --- /dev/null +++ b/secretspec-php/src/MissingRequiredException.php @@ -0,0 +1,22 @@ + $missing the names of the required secrets that are absent */ + public function __construct(public readonly array $missing) + { + parent::__construct( + 'missing_required', + 'missing required secret(s): ' . \implode(', ', $missing), + ); + } +} diff --git a/secretspec-php/src/Native.php b/secretspec-php/src/Native.php new file mode 100644 index 00000000..db3e270d --- /dev/null +++ b/secretspec-php/src/Native.php @@ -0,0 +1,183 @@ +secretspec_abi_version(); + + return \is_string($ret) ? $ret : \FFI::string($ret); + } + + /** + * The FFI fallback: dlopen the cdylib and call the C ABI. The returned C + * allocation is copied into a PHP string and freed before we return, so no + * native memory outlives the call. + */ + private static function resolveViaFfi(string $requestJson): string + { + $ffi = self::ffi(); + $ptr = $ffi->secretspec_resolve($requestJson); + // secretspec_resolve returns null only on catastrophic allocation failure. + if ($ptr === null || \FFI::isNull($ptr)) { + throw new SecretSpecException('ffi', 'secretspec_resolve returned null'); + } + + try { + // \FFI::string copies the NUL-terminated bytes into a PHP string here, + // before the finally frees the C pointer. + return \FFI::string($ptr); + } finally { + $ffi->secretspec_free($ptr); + } + } + + /** Lazily dlopen the shared library and bind the ABI once per process. */ + private static function ffi(): \FFI + { + if (self::$ffi === null) { + if (!\extension_loaded('ffi')) { + throw new SecretSpecException( + 'load', + 'the PHP FFI extension is required; enable ext-ffi (and set ffi.enable) ' + . 'to use the SecretSpec SDK', + ); + } + self::$ffi = \FFI::cdef(self::CDEF, self::locateLibrary()); + } + + return self::$ffi; + } + + /** + * Find `libsecretspec_ffi`: the `SECRETSPEC_FFI_LIB` override first, then a + * copy bundled in the package's `lib/` directory (the installed layout), then + * the nearest Cargo `target/` directory (a source checkout). + * + * @throws SecretSpecException if no library can be found. + */ + private static function locateLibrary(): string + { + $env = \getenv('SECRETSPEC_FFI_LIB'); + if (\is_string($env) && $env !== '') { + return $env; + } + + $name = self::libraryFileName(); + + // A copy bundled alongside the package (distribution layout). + $bundled = \dirname(__DIR__) . \DIRECTORY_SEPARATOR . 'lib' . \DIRECTORY_SEPARATOR . $name; + if (\is_file($bundled)) { + return $bundled; + } + + // Walk up from the package looking for a Cargo target dir; pick the most + // recently built library so a stale release build does not shadow the + // debug build a developer just produced. + $dir = __DIR__; + while (true) { + $best = null; + $bestMtime = -1; + foreach (['release', 'debug'] as $profile) { + $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; + } + } + } + if ($best !== null) { + return $best; + } + $parent = \dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; + } + + throw new SecretSpecException( + 'load', + 'could not locate the secretspec-ffi library; set SECRETSPEC_FFI_LIB to its path', + ); + } + + /** + * The platform-specific `libsecretspec_ffi` 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 match (\PHP_OS_FAMILY) { + 'Darwin' => 'libsecretspec_ffi.dylib', + 'Windows' => 'secretspec_ffi.dll', + default => 'libsecretspec_ffi.so', + }; + } +} diff --git a/secretspec-php/src/Report.php b/secretspec-php/src/Report.php new file mode 100644 index 00000000..f8b40c93 --- /dev/null +++ b/secretspec-php/src/Report.php @@ -0,0 +1,22 @@ + $secrets one entry per declared secret */ + public function __construct( + public readonly string $provider, + public readonly string $profile, + public readonly array $secrets, + ) { + } +} diff --git a/secretspec-php/src/Resolved.php b/secretspec-php/src/Resolved.php new file mode 100644 index 00000000..600362ea --- /dev/null +++ b/secretspec-php/src/Resolved.php @@ -0,0 +1,78 @@ + $secrets resolved secrets by declared name + * @param list $missingOptional optional secrets that were not found + */ + public function __construct( + public readonly string $provider, + public readonly string $profile, + public readonly array $secrets, + public readonly array $missingOptional = [], + ) { + } + + /** + * Export each resolved secret into the environment by its declared name, + * setting it for `getenv()` (via `putenv`) as well as `$_ENV` / `$_SERVER` + * so framework `env()` helpers see it too. Secrets with no usable value + * (e.g. under `withNoValues`) are skipped rather than exported as empty. + */ + public function setAsEnv(): void + { + foreach ($this->secrets as $name => $secret) { + $value = $secret->get(); + if ($value !== null) { + \putenv("{$name}={$value}"); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + } + } + + /** + * Flat `{SECRET_NAME: value}` map (the file path for `as_path`). A secret + * with no usable value (e.g. under `withNoValues`) maps to `null`, matching + * the null the other SDKs emit. + * + * This is the input for a quicktype-generated deserializer: feed it to the + * generated type's `from` method to get a typed object. See `secretspec schema`. + * + * @return array + */ + public function fields(): array + { + $out = []; + foreach ($this->secrets as $name => $secret) { + $out[$name] = $secret->get(); + } + + return $out; + } + + /** + * Remove the temp files backing any `as_path` secrets in this result. The + * resolver persists those files (mode 0400) so their paths stay valid after + * resolve returns; the caller owns their lifetime. Call `close()` when done + * so secret files do not accumulate in the temp dir. A file already gone is + * not an error. + */ + public function close(): void + { + foreach ($this->secrets as $secret) { + if ($secret->asPath && $secret->path !== null && \is_file($secret->path)) { + @\unlink($secret->path); + } + } + } +} diff --git a/secretspec-php/src/ResolvedSecret.php b/secretspec-php/src/ResolvedSecret.php new file mode 100644 index 00000000..a66c0dbe --- /dev/null +++ b/secretspec-php/src/ResolvedSecret.php @@ -0,0 +1,28 @@ +asPath ? $this->path : $this->value; + } +} diff --git a/secretspec-php/src/SecretReport.php b/secretspec-php/src/SecretReport.php new file mode 100644 index 00000000..05260822 --- /dev/null +++ b/secretspec-php/src/SecretReport.php @@ -0,0 +1,27 @@ +withProvider('keyring://') + * ->withProfile('production') + * ->withReason('boot web app') + * ->load(); + * + * echo $resolved->secrets['DATABASE_URL']->get(); + * $resolved->setAsEnv(); + * ``` + */ +final class SecretSpec +{ + /** Start a fluent {@see Builder}. */ + public static function builder(): Builder + { + return new Builder(); + } + + /** + * Convenience one-shot resolve. Equivalent to building and calling + * {@see Builder::load()}. + * + * @throws MissingRequiredException if a required secret is missing + * @throws SecretSpecException for any other failure + */ + public static function resolve( + ?string $path = null, + ?string $provider = null, + ?string $profile = null, + ?string $reason = null, + ): Resolved { + return self::configured($path, $provider, $profile, $reason)->load(); + } + + /** + * Convenience one-shot value-free {@see Report}. Equivalent to building and + * calling {@see Builder::report()}. + * + * @throws SecretSpecException for a transport failure + */ + public static function report( + ?string $path = null, + ?string $provider = null, + ?string $profile = null, + ?string $reason = null, + ): Report { + return self::configured($path, $provider, $profile, $reason)->report(); + } + + /** Build a {@see Builder} from the shared one-shot options. */ + private static function configured( + ?string $path, + ?string $provider, + ?string $profile, + ?string $reason, + ): Builder { + return self::builder() + ->withPath($path) + ->withProvider($provider) + ->withProfile($profile) + ->withReason($reason); + } + + /** The ABI version reported by the loaded native library. */ + public static function abiVersion(): string + { + return Native::abiVersion(); + } +} diff --git a/secretspec-php/src/SecretSpecException.php b/secretspec-php/src/SecretSpecException.php new file mode 100644 index 00000000..2c886770 --- /dev/null +++ b/secretspec-php/src/SecretSpecException.php @@ -0,0 +1,20 @@ + */ + public static function fixtures(): iterable + { + foreach (\glob(self::FIXTURES . '/*', \GLOB_ONLYDIR) ?: [] as $dir) { + yield \basename($dir) => [$dir]; + } + } + + #[DataProvider('fixtures')] + public function testConformance(string $dir): void + { + $expected = self::readJson($dir . '/expected.json'); + // Close the Resolved so as_path temp files do not accumulate. + $resolved = $this->builder($dir)->load(); + try { + self::assertEquals($expected, $this->canonical($resolved)); + } finally { + $resolved->close(); + } + } + + /** + * Under no_values every SDK must emit the same all-null fields map: a + * value-less secret serializes to null, not an empty string. + */ + #[DataProvider('fixtures')] + public function testConformanceNoValues(string $dir): void + { + $expected = self::readJson($dir . '/expected_no_values.json'); + $resolved = $this->builder($dir)->withNoValues()->load(); + self::assertEquals($expected, $resolved->fields()); + } + + /** The value-free report (status + provenance) is identical across SDKs. */ + #[DataProvider('fixtures')] + public function testConformanceReport(string $dir): void + { + $expected = self::readJson($dir . '/expected_report.json'); + self::assertEquals($expected, $this->canonicalReport($this->builder($dir)->report())); + } + + private function builder(string $dir): Builder + { + return SecretSpec::builder() + ->withPath($dir . '/secretspec.toml') + ->withProvider('dotenv://' . $dir . '/.env') + ->withReason('conformance'); + } + + /** @return array */ + private function canonical(Resolved $resolved): array + { + $secrets = []; + foreach ($resolved->secrets as $name => $secret) { + $value = $secret->asPath ? \file_get_contents($secret->get()) : $secret->value; + $secrets[$name] = ['value' => $value, 'source' => $secret->source, 'as_path' => $secret->asPath]; + } + $missingOptional = $resolved->missingOptional; + \sort($missingOptional); + + return [ + 'profile' => $resolved->profile, + 'secrets' => $secrets, + 'missing_required' => [], + 'missing_optional' => $missingOptional, + ]; + } + + /** @return array */ + private function canonicalReport(Report $report): array + { + $secrets = []; + foreach ($report->secrets as $s) { + $secrets[$s->name] = [ + 'status' => $s->status, + 'required' => $s->required, + 'as_path' => $s->asPath, + 'generated' => $s->generated, + 'default_applied' => $s->defaultApplied, + // Present-or-not (not the path-dependent value) so the vector is + // machine-independent yet still catches a dropped source_provider. + 'source_provider' => $s->sourceProvider !== null, + ]; + } + + return ['profile' => $report->profile, 'secrets' => $secrets]; + } + + /** @return array */ + private static function readJson(string $path): array + { + return \json_decode((string) \file_get_contents($path), true, 512, \JSON_THROW_ON_ERROR); + } +} diff --git a/secretspec-php/tests/ResolveTest.php b/secretspec-php/tests/ResolveTest.php new file mode 100644 index 00000000..b1fb7a9f --- /dev/null +++ b/secretspec-php/tests/ResolveTest.php @@ -0,0 +1,179 @@ + directories to remove after each test */ + private array $tmpDirs = []; + + protected function tearDown(): void + { + foreach ($this->tmpDirs as $dir) { + self::removeDir($dir); + } + $this->tmpDirs = []; + } + + /** + * Write a manifest + `.env` into a fresh temp dir. + * + * @return array{0: string, 1: string} the manifest path and a `dotenv://` provider + */ + private function project(string $dotenv, string $manifest = self::MANIFEST): array + { + $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'secretspec-php-' . \bin2hex(\random_bytes(8)); + \mkdir($dir); + $this->tmpDirs[] = $dir; + + $manifestPath = $dir . \DIRECTORY_SEPARATOR . 'secretspec.toml'; + $envPath = $dir . \DIRECTORY_SEPARATOR . '.env'; + \file_put_contents($manifestPath, $manifest); + \file_put_contents($envPath, $dotenv); + + return [$manifestPath, 'dotenv://' . $envPath]; + } + + public function testAbiVersionNonEmpty(): void + { + self::assertNotEmpty(SecretSpec::abiVersion()); + } + + public function testLoadValuesAndProvenance(): void + { + [$manifest, $provider] = $this->project("DATABASE_URL=postgres://db\n"); + + $resolved = SecretSpec::builder() + ->withPath($manifest) + ->withProvider($provider) + ->withReason('php test') + ->load(); + + self::assertSame('default', $resolved->profile); + + $db = $resolved->secrets['DATABASE_URL']; + self::assertSame('postgres://db', $db->get()); + self::assertSame('provider', $db->source); + self::assertNotNull($db->sourceProvider); + + $log = $resolved->secrets['LOG_LEVEL']; + self::assertSame('info', $log->get()); + self::assertSame('default', $log->source); + + self::assertSame(['SENTRY_DSN'], $resolved->missingOptional); + self::assertArrayNotHasKey('SENTRY_DSN', $resolved->secrets); + } + + public function testSetAsEnv(): void + { + [$manifest, $provider] = $this->project("DATABASE_URL=postgres://db\n"); + \putenv('DATABASE_URL'); + unset($_ENV['DATABASE_URL'], $_SERVER['DATABASE_URL']); + + SecretSpec::builder() + ->withPath($manifest) + ->withProvider($provider) + ->withReason('php test') + ->load() + ->setAsEnv(); + + self::assertSame('postgres://db', \getenv('DATABASE_URL')); + self::assertSame('postgres://db', $_ENV['DATABASE_URL']); + + \putenv('DATABASE_URL'); + unset($_ENV['DATABASE_URL'], $_SERVER['DATABASE_URL']); + } + + public function testMissingRequiredRaises(): void + { + [$manifest, $provider] = $this->project(''); + + try { + SecretSpec::builder() + ->withPath($manifest) + ->withProvider($provider) + ->withReason('php test') + ->load(); + self::fail('expected MissingRequiredException'); + } catch (MissingRequiredException $e) { + self::assertContains('DATABASE_URL', $e->missing); + } + } + + public function testAsPathReturnsReadableFile(): void + { + $manifest = <<<'TOML' + [project] + name = "php-test" + revision = "1.0" + + [profiles.default] + TLS_CERT = { description = "cert", required = true, as_path = true } + TOML; + [$manifestPath, $provider] = $this->project("TLS_CERT=----cert----\n", $manifest); + + $resolved = SecretSpec::builder() + ->withPath($manifestPath) + ->withProvider($provider) + ->withReason('php test') + ->load(); + + try { + $cert = $resolved->secrets['TLS_CERT']; + self::assertTrue($cert->asPath); + self::assertNull($cert->value); + self::assertSame('----cert----', \file_get_contents($cert->get())); + } finally { + // Remove the 0400 as_path temp file so no secret-bearing file lingers. + $resolved->close(); + } + } + + public function testInvalidManifestRaisesError(): void + { + try { + SecretSpec::builder() + ->withPath('/definitely/does/not/exist/secretspec.toml') + ->withReason('php test') + ->load(); + self::fail('expected SecretSpecException'); + } catch (MissingRequiredException $e) { + self::fail('expected a transport error, not MissingRequiredException'); + } catch (SecretSpecException $e) { + self::assertNotEmpty($e->kind); + } + } + + private static function removeDir(string $dir): void + { + if (!\is_dir($dir)) { + return; + } + foreach (\scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . \DIRECTORY_SEPARATOR . $entry; + \is_dir($path) ? self::removeDir($path) : @\unlink($path); + } + @\rmdir($dir); + } +} diff --git a/secretspec/README.md b/secretspec/README.md index f3e6c02c..1225945b 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -190,6 +190,7 @@ works identically with no per-language resolution logic: - [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) +- [PHP](https://secretspec.dev/sdk/php) (ext-php-rs extension, with an `ext-ffi` fallback) ```python from secretspec import SecretSpec