From 87399f31a3855e8863fce2b4df7815d4aa08ba1c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 2 Jun 2026 21:33:44 -0400 Subject: [PATCH 1/4] feat: add ffi pre-built .a library Signed-off-by: Frederico Araujo --- .github/workflows/release-ffi.yaml | 172 ++++++++++++++++++++++ CHANGELOG.md | 13 ++ crates/cpex-ffi/RELEASE.md | 222 +++++++++++++++++++++++++++++ crates/cpex-ffi/src/lib.rs | 44 ++++++ go/cpex/abi.go | 50 +++++++ scripts/download-ffi-artifact.sh | 169 ++++++++++++++++++++++ scripts/release/build-artifact.sh | 120 ++++++++++++++++ scripts/release/sign-artifact.sh | 61 ++++++++ 8 files changed, 851 insertions(+) create mode 100644 .github/workflows/release-ffi.yaml create mode 100644 crates/cpex-ffi/RELEASE.md create mode 100644 go/cpex/abi.go create mode 100755 scripts/download-ffi-artifact.sh create mode 100755 scripts/release/build-artifact.sh create mode 100755 scripts/release/sign-artifact.sh diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml new file mode 100644 index 00000000..4ae26b79 --- /dev/null +++ b/.github/workflows/release-ffi.yaml @@ -0,0 +1,172 @@ +# =============================================================== +# Release FFI - Build, sign, and publish libcpex_ffi.a artifacts +# =============================================================== +# +# Triggered by semver-strict tag pushes. Matrix-builds the FFI +# static library for the supported target tuples, packages each into +# a tarball with VERSION / FFI_ABI / LICENSE metadata, signs every +# tarball + the aggregate SHA256SUMS with cosign keyless (Sigstore), +# and attaches everything to the GitHub Release for the tag. +# +# See crates/cpex-ffi/RELEASE.md for the artifact schema and the +# consumer-side verify-and-unpack recipe. + +name: Release FFI + +on: + push: + tags: + # Semver-strict. Two patterns so vMAJOR.MINOR.PATCH (release) + # and vMAJOR.MINOR.PATCH- (rc / beta / ffi.test) + # both fire, while loose `v*` matches (vendor-bump, v1, v-foo) + # and the legacy non-prefixed tags (0.1.0, plugins.dev1) do not. + # Dry-run tags like v0.0.0-ffi.test.1 deliberately hit the + # prerelease branch. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + +# id-token: write is what unlocks Sigstore keyless signing (Fulcio +# reads the GHA OIDC token to issue the short-lived signing cert). +# contents: write is needed to create / upload to the GitHub Release. +permissions: + contents: write + id-token: write + +# Prevent concurrent runs on the same tag from racing the release +# creation. Tag pushes are one-shot, so this is belt-and-suspenders. +concurrency: + group: release-ffi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false # one tuple failing should not cancel the others + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + - target: aarch64-unknown-linux-musl + runner: ubuntu-22.04-arm + - target: aarch64-apple-darwin + runner: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + # dtolnay/rust-toolchain is the de-facto rustup action. + # `stable` picks the latest stable; pin if we need a floor. + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + # Share cache across tags (the key includes the target + + # Cargo.lock hash by default). Significant speedup on + # subsequent releases of the same target. + key: ${{ matrix.target }} + + - name: Build artifact + env: + TARGET: ${{ matrix.target }} + # VERSION drops the leading "refs/tags/" so the tarball + # name matches the tag verbatim. + VERSION: ${{ github.ref_name }} + DIST_DIR: dist + run: bash scripts/release/build-artifact.sh + + - name: Upload tarball + sha256 + uses: actions/upload-artifact@v4 + with: + # Unique per-target name so the download step in + # sign-and-release can merge them all into one dist/. + name: cpex-ffi-${{ github.ref_name }}-${{ matrix.target }} + path: dist/cpex-ffi-* + if-no-files-found: error + retention-days: 7 + + sign-and-release: + name: Sign and publish release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: dist + # merge-multiple flattens the per-target subdirs into one + # dist/ so the sign script and gh release upload see a + # flat layout. + merge-multiple: true + + - name: Generate aggregate SHA256SUMS + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + cd dist + # Concat all individual .sha256 files into one signed + # integrity manifest. The per-tarball .sha256 files stay + # as convenience companions, but the SHA256SUMS file is + # what auditors care about. + : > "cpex-ffi-${VERSION}-SHA256SUMS" + for f in cpex-ffi-*.tar.gz; do + if command -v sha256sum >/dev/null; then + sha256sum "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + else + shasum -a 256 "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + fi + done + echo "--- SHA256SUMS ---" + cat "cpex-ffi-${VERSION}-SHA256SUMS" + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign artifacts + env: + DIST_DIR: dist + run: bash scripts/release/sign-artifact.sh + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Auto-detect prerelease by suffix so dry-run tags + # (v0.0.0-ffi.test.1) land as prereleases and don't surface + # as "latest" on the repo's Releases page. + PRERELEASE_FLAG="" + if [[ "$TAG" == *-* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Idempotent: if the release exists, upload --clobber the + # new files; if it doesn't, create it with the tarballs + + # SHA256SUMS + sigs in one shot. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists; uploading artifacts with --clobber" + gh release upload "$TAG" dist/cpex-ffi-* --clobber + else + echo "Creating release $TAG" + gh release create "$TAG" \ + --title "$TAG" \ + --notes "Automated FFI artifact release. See crates/cpex-ffi/RELEASE.md for the schema and verify-and-consume recipe." \ + $PRERELEASE_FLAG \ + dist/cpex-ffi-* + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index c0185621..f0e6a978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on + every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, + `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign + keyless signatures + SHA256 checksums; see + `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- + consume recipe. +- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor + exposes `FFI_ABI_VERSION` (currently `1`). The Go binding checks + this in `init()` and panics on mismatch. Other language bindings + must replicate the check. + ## [0.1.0] - 2026-05-05 ### Added diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md new file mode 100644 index 00000000..c52234fc --- /dev/null +++ b/crates/cpex-ffi/RELEASE.md @@ -0,0 +1,222 @@ +# `libcpex_ffi.a` — Release Artifacts + +CPEX publishes pre-built `libcpex_ffi.a` static libraries as signed +GitHub Release artifacts. Downstream consumers (Go bindings, +language bindings, anyone embedding CPEX) link against these +without needing a Rust toolchain. + +This document covers what is published, how to consume and verify +an artifact, and the FFI ABI policy that makes the contract durable. + +## What is published + +Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or +`vMAJOR.MINOR.PATCH-`) attaches one tarball per +supported target tuple to the GitHub Release, along with checksums +and signatures. + +### Naming and layout + +For release `vX.Y.Z` and tuple `-[-]`: + +``` +cpex-ffi-vX.Y.Z--[-].tar.gz +cpex-ffi-vX.Y.Z--[-].tar.gz.sha256 +cpex-ffi-vX.Y.Z--[-].tar.gz.sig +cpex-ffi-vX.Y.Z--[-].tar.gz.crt +``` + +Plus one aggregate integrity manifest for the whole release: + +``` +cpex-ffi-vX.Y.Z-SHA256SUMS +cpex-ffi-vX.Y.Z-SHA256SUMS.sig +cpex-ffi-vX.Y.Z-SHA256SUMS.crt +``` + +Each tarball, when extracted, contains: + +| File | Contents | +|-------------------|---------------------------------------------------| +| `libcpex_ffi.a` | Static library — the actual deliverable. | +| `VERSION` | Plain text. Keys: `version`, `git_sha`, `build_date`, `tuple`, `rust_target`. | +| `FFI_ABI` | Single integer line — FFI ABI version. See policy below. | +| `LICENSE` | Copy of CPEX's Apache-2.0 license. | + +Tarballs are flat (no leading directory). `tar xzf -C ` drops the four files directly into ``. + +### Target matrix + +| Tuple | Rust target triple | Runner | +|----------------------|---------------------------------|-----------------| +| `linux-amd64-gnu` | `x86_64-unknown-linux-gnu` | `ubuntu-latest` | +| `linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | `ubuntu-22.04-arm` | +| `linux-amd64-musl` | `x86_64-unknown-linux-musl` | `ubuntu-latest` | +| `linux-arm64-musl` | `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` | +| `darwin-arm64` | `aarch64-apple-darwin` | `macos-14` | + +`darwin-amd64` and Windows targets are not built in v1. Open an +issue if you need one — adding to the matrix is mechanical. + +### Signing + +Tarballs and the aggregate `SHA256SUMS` are signed with +[cosign](https://github.com/sigstore/cosign) **keyless** via +Sigstore (Fulcio for cert issuance, Rekor for transparency). There +is no long-lived signing key — each release produces short-lived +certs bound to the GitHub Actions OIDC identity of the +`release-ffi.yaml` workflow on the canonical repo. Verification +checks both the cert subject and the OIDC issuer. + +## How to consume + +### One-shot: the helper script + +The repo ships `scripts/download-ffi-artifact.sh` — vendor it +into your build (or fetch via `raw.githubusercontent.com` pinned to +a tag) and call it before `go build` / `cargo build` / etc. + +```sh +export CPEX_FFI_VERSION=v0.9.0 +ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) +export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" +go build ./... +``` + +What it does: + +1. Auto-detects your tuple from `uname -s` / `uname -m` (override + with `CPEX_FFI_TARGET`). +2. Downloads the tarball, `.sha256`, `.sig`, `.crt`. +3. Verifies the SHA256 — non-skippable. +4. Verifies the cosign signature against the canonical workflow + identity and OIDC issuer — skippable via + `CPEX_FFI_SKIP_COSIGN=1` only for air-gapped environments. +5. Unpacks to `${CPEX_FFI_DEST}` (default + `./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/`). +6. Prints the absolute destination to stdout. + +Subsequent runs against the same version + dest are no-ops. + +### Manual: cosign + tar + +If you want to do it by hand: + +```sh +VER=v0.9.0 +TUPLE=linux-amd64-gnu +BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" +NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" + +curl -fsSLO "${BASE}/${NAME}" +curl -fsSLO "${BASE}/${NAME}.sha256" +curl -fsSLO "${BASE}/${NAME}.sig" +curl -fsSLO "${BASE}/${NAME}.crt" + +sha256sum -c "${NAME}.sha256" + +cosign verify-blob \ + --certificate "${NAME}.crt" \ + --signature "${NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/contextforge-org/cpex/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${NAME}" + +mkdir -p ./libcpex +tar xzf "${NAME}" -C ./libcpex +``` + +After this, `./libcpex/libcpex_ffi.a` is your link target. + +### Using with the in-tree Go binding + +`go/cpex/ffi.go` links via `#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi` +relative to the cpex repo layout. For downstream Go consumers that +pull `go/cpex` via `go get`, set `CGO_LDFLAGS` to point at the +unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be +augmented by the env var: + +```sh +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.9.0 bash scripts/download-ffi-artifact.sh) +CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... +``` + +## FFI ABI policy + +The `FFI_ABI` integer in each bundle declares the wire-level C +contract version that `libcpex_ffi.a` exposes. Language bindings +must record the ABI version they were generated against and check +it at runtime — the Go binding does this in `go/cpex/abi.go`'s +`init()` and panics on mismatch. Every other binding **must do the +same**; silent acceptance of an ABI mismatch produces undefined +behavior on every subsequent FFI call. + +### What counts as an ABI break + +A bump of `FFI_ABI_VERSION` is **required** for any of: + +- Adding, removing, or renaming an `extern "C"` function. +- Changing argument count, argument type, or return type of an + existing extern function. +- Changing the layout of a struct that crosses the boundary. +- Changing the ownership or lifetime contract of a pointer + returned from / accepted by an extern function. +- Changing the semantics of a return code for a previously-success + case (e.g. a function that used to return `RC_OK` now returns a + new code on the same input). + +A bump is **not** required for: + +- Adding a new `RC_*` code at the end of the existing range. + Existing wire codes are stable; consumers should treat unknown + codes as generic failure. +- Internal Rust refactors that leave the C surface unchanged. +- Documentation / comment changes. + +### Process + +1. The Rust author bumps `FFI_ABI_VERSION` in + `crates/cpex-ffi/src/lib.rs` in the same PR as the breaking + change. +2. All in-tree language bindings (today: `go/cpex/abi.go`'s + `expectedFFIABIVersion`) are bumped to match in the same PR. +3. `CHANGELOG.md` records the bump under **Changed** with the + from→to integers and a one-line description of what moved. +4. The release tag that ships the breaking change is a new + `MINOR` (or `MAJOR`) — never a `PATCH`. + +## Versioning + +The artifact tag matches the CPEX repo tag exactly. There is no +separate "FFI version" — `vX.Y.Z` of CPEX produces `cpex-ffi-vX.Y.Z-*` +artifacts. Prereleases (`vX.Y.Z-rc1`, `vX.Y.Z-beta.1`, +`vX.Y.Z-ffi.test.1`, etc.) publish too and land as GitHub Releases +flagged "prerelease" — they don't surface as "latest". + +The FFI ABI version is independent: a release that doesn't touch +the C surface keeps the same `FFI_ABI`, even across minor / major +CPEX bumps. + +## Reproducibility caveats + +Builds use `cargo build --release --locked`, which pins the +`Cargo.lock` resolution. Beyond that, no guarantees: + +- Timestamps in the built `.a` differ between runs. +- Compiler / OS image patch versions on the runner can shift. +- macOS code-signing metadata varies per build. + +Consumers care about `FFI_ABI` (contract stability) and SHA + cosign +(integrity + authenticity), not bit-identical reproducibility. +Adding `cargo-zigbuild` or a sysroot-pinning toolchain to harden +reproducibility is a v2 ask. + +## When something is wrong + +| Symptom | Likely cause / fix | +|----------------------------------|----------------------------------------------------------------------------| +| `cosign verify-blob` fails | Wrong `--certificate-identity-regexp` (must point at the canonical repo's `release-ffi.yaml`), or the artifact came from a fork rather than the canonical workflow. | +| sha256 mismatch | The download was corrupted or the upstream release was rewritten. Open an issue. | +| Go `init` panics with ABI mismatch | The linked `.a` and the Go binding were generated against different ABI versions. Pin both to the same CPEX tag. | +| Unsupported tuple | Your platform isn't in the matrix. Either add it (PR welcome) or build the `.a` locally from source. | +| `tar` complains about absolute paths | Bundles are flat (no leading dir). Extract with `tar xzf -C `, not into the current dir. | diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 760f62d9..9c22a4b7 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -59,6 +59,50 @@ pub const RC_TIMEOUT: c_int = -6; /// Plugin panicked; caught by `catch_unwind` at the FFI boundary. pub const RC_PANIC: c_int = -7; +// --------------------------------------------------------------------------- +// FFI ABI Version +// --------------------------------------------------------------------------- +// +// The FFI ABI version is an integer that identifies the C-surface +// contract this crate exposes. Bump it on any breaking change to the +// C surface: +// +// - added / removed / renamed extern "C" function +// - argument count, argument type, or return type change on an +// existing function +// - layout change of a struct that crosses the boundary +// - semantic change to an existing function (e.g. new RC_* value +// returned for a previously-success case, change in pointer +// ownership) +// +// Adding a new RC_* code at the end of the existing range is *not* a +// breaking change (the wire codes are stable; consumers handle unknown +// codes as generic failure). +// +// Consumers — every language binding — MUST call `cpex_ffi_abi_version` +// at init and compare against the version their binding was generated +// for. Mismatch is a hard error: the C surface they generated against +// is not the one they're linked against. Document the binding's +// expected ABI version in its source. +// +// Bumps are recorded in CHANGELOG.md under "Changed" with the from→to +// integers and a one-line description of what moved. + +/// FFI ABI version. Bump on breaking C-surface changes; see module +/// docs above for what counts as breaking. +pub const FFI_ABI_VERSION: u32 = 1; + +/// Returns the FFI ABI version this `libcpex_ffi` was built with. +/// Language bindings call this at `init` and panic on mismatch +/// against the version they were generated for. +/// +/// Pure const access — no allocation, no runtime, no panics. Safe to +/// call from anywhere including signal handlers. +#[no_mangle] +pub extern "C" fn cpex_ffi_abi_version() -> u32 { + FFI_ABI_VERSION +} + /// Outer wall-clock timeout for any FFI-driven async call. Per-plugin /// `tokio::time::timeout` only catches cooperative-async timeouts; this /// catches CPU-bound or thread-blocking plugins that never yield. Set diff --git a/go/cpex/abi.go b/go/cpex/abi.go new file mode 100644 index 00000000..d5e7d374 --- /dev/null +++ b/go/cpex/abi.go @@ -0,0 +1,50 @@ +// Location: ./go/cpex/abi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FFI ABI version check. +// +// On package init, calls cpex_ffi_abi_version() and panics if the +// linked libcpex_ffi reports an ABI version different from what this +// Go binding was generated against. A mismatch means the C surface +// the bindings expect is not the one libcpex_ffi exposes — every +// other cgo call in this package would have undefined behavior, so +// failing loud at init is preferred over silent corruption later. +// +// Bumping expectedFFIABIVersion is required (and only required) when +// the Rust crate bumps FFI_ABI_VERSION. See crates/cpex-ffi/src/lib.rs +// "FFI ABI Version" section for what counts as a breaking change. + +package cpex + +/* +#include + +// Duplicated from ffi.go / manager.go preambles — see the note in +// manager.go about cgo not merging declarations across files. +extern uint32_t cpex_ffi_abi_version(void); +*/ +import "C" + +import "fmt" + +// expectedFFIABIVersion is the FFI_ABI_VERSION integer this binding +// was generated against. Bump in lockstep with the Rust crate's +// FFI_ABI_VERSION whenever the C surface changes in a breaking way. +const expectedFFIABIVersion uint32 = 1 + +func init() { + actual := uint32(C.cpex_ffi_abi_version()) + if actual != expectedFFIABIVersion { + panic(fmt.Sprintf( + "cpex: FFI ABI version mismatch — Go binding expects %d, "+ + "linked libcpex_ffi reports %d. Upgrade github.com/"+ + "contextforge-org/contextforge-plugins-framework/go/cpex "+ + "to a version generated against libcpex_ffi ABI %d, "+ + "or rebuild libcpex_ffi from a CPEX commit whose "+ + "FFI_ABI_VERSION is %d.", + expectedFFIABIVersion, actual, actual, expectedFFIABIVersion, + )) + } +} diff --git a/scripts/download-ffi-artifact.sh b/scripts/download-ffi-artifact.sh new file mode 100755 index 00000000..a72eb5cc --- /dev/null +++ b/scripts/download-ffi-artifact.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Location: ./scripts/download-ffi-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Consumer-facing script. Downloads a published libcpex_ffi.a +# release tarball from the CPEX GitHub Releases, verifies its +# sha256 + cosign signature, and unpacks it into a directory ready +# for cgo to link from. +# +# Intended for use in downstream Dockerfiles and CI jobs that don't +# want a Rust toolchain. Vendor this file (or fetch it pinned by tag +# from raw.githubusercontent.com) and call it before `go build`. +# +# Inputs (env or flag-equivalent CLI args): +# CPEX_FFI_VERSION Required. Tag of the release, e.g. v0.9.0. +# CPEX_FFI_TARGET Optional. Tuple name (linux-amd64-gnu, +# linux-arm64-gnu, linux-amd64-musl, +# linux-arm64-musl, darwin-arm64). Auto-detected +# from `uname -s` / `uname -m` + libc probe if unset. +# CPEX_FFI_DEST Optional. Destination directory. Defaults to +# ./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/. +# CPEX_FFI_REPO Optional. GitHub owner/repo override. Defaults +# to contextforge-org/cpex. +# CPEX_FFI_BASE_URL Optional. Full URL prefix override (skips the +# github.com/releases/download URL construction). +# Used for local file:// dry-runs. +# CPEX_FFI_SKIP_COSIGN +# Optional. Set to "1" to skip cosign verification. +# sha256 verification is never skipped. Only for +# air-gapped / offline environments where cosign +# cannot reach Sigstore. Document the risk. +# +# Output: +# Prints the absolute destination directory to stdout on success. +# Consumers capture it with $(bash download-ffi-artifact.sh) and +# pass to CGO_LDFLAGS as `-L${dir} -lcpex_ffi`. +# +# Idempotency: +# If ${dest}/VERSION exists and its first "version=..." line matches +# CPEX_FFI_VERSION, the script exits 0 without re-downloading. + +set -euo pipefail + +err() { echo "download-ffi-artifact: error: $*" >&2; exit 1; } +info() { echo "download-ffi-artifact: $*" >&2; } # stderr — stdout is the dest path + +: "${CPEX_FFI_VERSION:?CPEX_FFI_VERSION is required (e.g. v0.9.0)}" +CPEX_FFI_REPO="${CPEX_FFI_REPO:-contextforge-org/cpex}" + +# Detect target tuple if not provided. Inverse of the mapping in +# build-artifact.sh. +detect_tuple() { + local os arch libc="" + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + # Probe for musl vs gnu. ldd --version writes to stderr; + # musl's ldd prints "musl libc" on stderr too, gnu prints + # "GLIBC". Fallback heuristic: presence of /lib/ld-musl-*. + if (ldd --version 2>&1 || true) | grep -qi musl; then + libc="musl" + elif compgen -G "/lib/ld-musl-*" >/dev/null; then + libc="musl" + else + libc="gnu" + fi + case "$arch" in + x86_64) echo "linux-amd64-${libc}" ;; + aarch64) echo "linux-arm64-${libc}" ;; + *) err "unsupported linux arch: $arch" ;; + esac + ;; + Darwin) + case "$arch" in + arm64) echo "darwin-arm64" ;; + x86_64) echo "darwin-amd64" ;; + *) err "unsupported darwin arch: $arch" ;; + esac + ;; + *) err "unsupported OS: $os" ;; + esac +} + +CPEX_FFI_TARGET="${CPEX_FFI_TARGET:-$(detect_tuple)}" +CPEX_FFI_DEST="${CPEX_FFI_DEST:-./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}}" + +info "version=$CPEX_FFI_VERSION target=$CPEX_FFI_TARGET dest=$CPEX_FFI_DEST" + +# Idempotency: a successful prior run leaves a VERSION file whose +# first line is "version=". If it matches, we're done. +if [[ -f "${CPEX_FFI_DEST}/VERSION" ]]; then + existing="$(head -n1 "${CPEX_FFI_DEST}/VERSION" | sed -E 's/^version=//')" + if [[ "$existing" == "$CPEX_FFI_VERSION" ]]; then + info "already present at $CPEX_FFI_DEST (version=$existing); skipping download" + cd "$CPEX_FFI_DEST" && pwd + exit 0 + fi + info "existing VERSION ($existing) != requested ($CPEX_FFI_VERSION); re-downloading" +fi + +TARBALL_NAME="cpex-ffi-${CPEX_FFI_VERSION}-${CPEX_FFI_TARGET}.tar.gz" +BASE_URL="${CPEX_FFI_BASE_URL:-https://github.com/${CPEX_FFI_REPO}/releases/download/${CPEX_FFI_VERSION}}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fetch() { + local name="$1" + local url="${BASE_URL}/${name}" + info " GET $url" + if [[ "$url" == file://* ]]; then + cp "${url#file://}" "${WORK_DIR}/${name}" \ + || err "failed to copy from $url" + else + curl -fsSL --retry 3 --retry-delay 2 -o "${WORK_DIR}/${name}" "$url" \ + || err "failed to download $url" + fi +} + +info "downloading release assets" +fetch "$TARBALL_NAME" +fetch "${TARBALL_NAME}.sha256" + +# sha256 verification — non-negotiable. The .sha256 file contains +# " "; sha256sum -c reads it and checks. macOS's +# shasum -a 256 -c uses the same format. +info "verifying sha256" +if command -v sha256sum >/dev/null; then + (cd "$WORK_DIR" && sha256sum -c "${TARBALL_NAME}.sha256") +else + (cd "$WORK_DIR" && shasum -a 256 -c "${TARBALL_NAME}.sha256") +fi + +# cosign verification — opt-out only. The certificate identity is the +# workflow path; the regex permits any tag ref so re-tagged releases +# still verify. The issuer is pinned to GitHub's OIDC issuer to +# prevent Sigstore certs from other providers from passing. +if [[ "${CPEX_FFI_SKIP_COSIGN:-0}" == "1" ]]; then + info "WARN: skipping cosign verification (CPEX_FFI_SKIP_COSIGN=1)" +else + command -v cosign >/dev/null || err "cosign is required for signature verification (or set CPEX_FFI_SKIP_COSIGN=1 to bypass — not recommended)" + fetch "${TARBALL_NAME}.sig" + fetch "${TARBALL_NAME}.crt" + info "verifying cosign signature" + cosign verify-blob \ + --certificate "${WORK_DIR}/${TARBALL_NAME}.crt" \ + --signature "${WORK_DIR}/${TARBALL_NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/${CPEX_FFI_REPO}/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${WORK_DIR}/${TARBALL_NAME}" \ + >/dev/null \ + || err "cosign verification failed" +fi + +# Unpack into the destination, replacing any prior contents at that +# version (the idempotency check above already handled the +# already-present case). +info "unpacking into $CPEX_FFI_DEST" +mkdir -p "$CPEX_FFI_DEST" +# Clear stale files from a partial earlier run; safe because we only +# touch our own version-stamped dir. +find "$CPEX_FFI_DEST" -mindepth 1 -delete +tar xzf "${WORK_DIR}/${TARBALL_NAME}" -C "$CPEX_FFI_DEST" + +# Print the absolute destination so consumer scripts can capture it. +(cd "$CPEX_FFI_DEST" && pwd) +info "done" diff --git a/scripts/release/build-artifact.sh b/scripts/release/build-artifact.sh new file mode 100755 index 00000000..01b6afdc --- /dev/null +++ b/scripts/release/build-artifact.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/build-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Build one libcpex_ffi.a for a given Rust target triple and stage it +# into a release tarball under dist/. +# +# Invoked once per matrix tuple by .github/workflows/release-ffi.yaml. +# Safe to run locally for the host tuple to validate the bundle shape. +# +# Inputs (env): +# TARGET Required. Rust target triple, e.g. x86_64-unknown-linux-gnu. +# VERSION Required in CI. Git tag, e.g. v0.9.0. Falls back to +# `git describe --tags --dirty` for local invocations. +# DIST_DIR Optional. Output dir for tarball + .sha256. Defaults to ./dist. +# USE_CROSS Optional. If "1", build with `cross` instead of `cargo`. +# Required for cross-compiling musl/arm targets without a +# pre-installed sysroot. +# +# Outputs: +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz.sha256 + +set -euo pipefail + +err() { echo "build-artifact: error: $*" >&2; exit 1; } +info() { echo "build-artifact: $*"; } + +: "${TARGET:?TARGET is required (e.g. x86_64-unknown-linux-gnu)}" +DIST_DIR="${DIST_DIR:-./dist}" +USE_CROSS="${USE_CROSS:-0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Version resolution. CI sets VERSION from the tag; locally fall back +# to git-describe so dev iterations get a sensible bundle name. +if [[ -z "${VERSION:-}" ]]; then + VERSION="$(git describe --tags --dirty --always 2>/dev/null || echo "v0.0.0-dev")" + info "VERSION not set; using git-describe fallback: $VERSION" +fi + +# Map Rust target triple → our tuple naming. This is the contract +# downstream consumers' download-ffi-artifact.sh inverts via uname. +case "$TARGET" in + x86_64-unknown-linux-gnu) TUPLE="linux-amd64-gnu" ;; + aarch64-unknown-linux-gnu) TUPLE="linux-arm64-gnu" ;; + x86_64-unknown-linux-musl) TUPLE="linux-amd64-musl" ;; + aarch64-unknown-linux-musl) TUPLE="linux-arm64-musl" ;; + aarch64-apple-darwin) TUPLE="darwin-arm64" ;; + x86_64-apple-darwin) TUPLE="darwin-amd64" ;; + *) err "unsupported TARGET: $TARGET (add a case in build-artifact.sh)" ;; +esac + +# Read FFI_ABI_VERSION from the crate source. Single source of truth — +# bumps in lib.rs flow into the bundle without a separate config edit. +ABI_LINE="$(grep -E '^pub const FFI_ABI_VERSION: u32 = [0-9]+;' \ + crates/cpex-ffi/src/lib.rs || true)" +[[ -n "$ABI_LINE" ]] || err "could not find FFI_ABI_VERSION in crates/cpex-ffi/src/lib.rs" +FFI_ABI="$(echo "$ABI_LINE" | sed -E 's/.*= ([0-9]+);.*/\1/')" +[[ "$FFI_ABI" =~ ^[0-9]+$ ]] || err "extracted FFI_ABI is not an integer: $FFI_ABI" + +info "TARGET=$TARGET TUPLE=$TUPLE VERSION=$VERSION FFI_ABI=$FFI_ABI" + +# Build. `cross` swaps in a containerized toolchain with the right +# sysroot/glibc/musl for the target — used for arm and musl from x86_64 +# linux runners. Local host builds use plain cargo. +if [[ "$USE_CROSS" == "1" ]]; then + command -v cross >/dev/null || err "USE_CROSS=1 but cross is not installed" + info "building with cross" + cross build --release --locked --target "$TARGET" -p cpex-ffi +else + info "building with cargo" + cargo build --release --locked --target "$TARGET" -p cpex-ffi +fi + +ARTIFACT_PATH="target/${TARGET}/release/libcpex_ffi.a" +[[ -f "$ARTIFACT_PATH" ]] || err "expected artifact missing: $ARTIFACT_PATH" + +# Stage into a temp dir, tar from there so the archive has no leading +# directory and tools like the download script can `tar xzf` flat into +# any destination. +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +cp "$ARTIFACT_PATH" "$STAGE_DIR/libcpex_ffi.a" +cp LICENSE "$STAGE_DIR/LICENSE" + +GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +cat > "$STAGE_DIR/VERSION" < "$STAGE_DIR/FFI_ABI" + +mkdir -p "$DIST_DIR" +TARBALL_NAME="cpex-ffi-${VERSION}-${TUPLE}.tar.gz" +TARBALL_PATH="${DIST_DIR}/${TARBALL_NAME}" + +# tar -C ${STAGE_DIR} . produces a flat archive (no leading dir). +# --owner / --group / --mtime would help reproducibility but BSD/GNU +# tar flag divergence makes that finicky; --locked + cargo gives us +# the most important reproducibility guarantee. +tar -czf "$TARBALL_PATH" -C "$STAGE_DIR" . + +# sha256 companion. Recompute on the consumer side as the integrity gate. +# Use coreutils sha256sum if present (linux), shasum -a 256 otherwise (macOS). +if command -v sha256sum >/dev/null; then + (cd "$DIST_DIR" && sha256sum "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +else + (cd "$DIST_DIR" && shasum -a 256 "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +fi + +info "wrote $TARBALL_PATH" +info "wrote ${TARBALL_PATH}.sha256" diff --git a/scripts/release/sign-artifact.sh b/scripts/release/sign-artifact.sh new file mode 100755 index 00000000..f306311a --- /dev/null +++ b/scripts/release/sign-artifact.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/sign-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Sign every tarball + SHA256SUMS file in DIST_DIR with cosign keyless +# (Sigstore Fulcio + Rekor). Produces a .sig and .crt next to each +# signed file so downstream consumers can verify without fetching keys. +# +# Invoked once by the sign-and-release job in release-ffi.yaml after +# all matrix-built tarballs are downloaded into dist/. Requires the +# workflow to have `id-token: write` so cosign can obtain the GitHub +# Actions OIDC token for keyless signing. +# +# Inputs (env): +# DIST_DIR Optional. Directory containing .tar.gz / SHA256SUMS files +# to sign. Defaults to ./dist. +# +# Outputs: +# For every cpex-ffi-*.tar.gz or cpex-ffi-*-SHA256SUMS in DIST_DIR: +# .sig +# .crt + +set -euo pipefail + +err() { echo "sign-artifact: error: $*" >&2; exit 1; } +info() { echo "sign-artifact: $*"; } + +DIST_DIR="${DIST_DIR:-./dist}" +[[ -d "$DIST_DIR" ]] || err "DIST_DIR does not exist: $DIST_DIR" + +command -v cosign >/dev/null || err "cosign is required (install before running)" + +# Sign tarballs and the aggregate SHA256SUMS bundle (if present). The +# per-tarball .sha256 companions are not signed individually — the +# SHA256SUMS file is the signed integrity manifest. The download +# script verifies the tarball's own signature directly, so the +# per-tarball .sha256 is convenience-only. +shopt -s nullglob +TO_SIGN=( "$DIST_DIR"/cpex-ffi-*.tar.gz "$DIST_DIR"/cpex-ffi-*-SHA256SUMS ) +shopt -u nullglob + +[[ ${#TO_SIGN[@]} -gt 0 ]] || err "no files to sign in $DIST_DIR" + +info "signing ${#TO_SIGN[@]} file(s) with cosign keyless" + +for f in "${TO_SIGN[@]}"; do + [[ -f "$f" ]] || continue + info " signing $(basename "$f")" + # --yes skips the interactive "open browser?" prompt — required for + # CI. The OIDC token is sourced automatically from the GHA env + # (ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN). --output-* writes the + # detached signature + cert so verifiers don't need Rekor lookups + # for the basics, though Rekor is still queried for transparency. + cosign sign-blob --yes \ + --output-signature "${f}.sig" \ + --output-certificate "${f}.crt" \ + "$f" +done + +info "done; signed ${#TO_SIGN[@]} file(s)" From ce31ae5801807b769c4af05a75a8fa35abb1da2d Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 3 Jun 2026 14:45:05 -0400 Subject: [PATCH 2/4] chore: add workflow_dispatch target Signed-off-by: Frederico Araujo --- .github/workflows/release-ffi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml index 4ae26b79..9cafbeb7 100644 --- a/.github/workflows/release-ffi.yaml +++ b/.github/workflows/release-ffi.yaml @@ -24,6 +24,7 @@ on: # prerelease branch. - 'v[0-9]+.[0-9]+.[0-9]+' - 'v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: # id-token: write is what unlocks Sigstore keyless signing (Fulcio # reads the GHA OIDC token to issue the short-lived signing cert). From c5b266705d1d05b2e2de9ccba8b61c3c6200cc94 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 3 Jun 2026 16:25:08 -0400 Subject: [PATCH 3/4] feat: add APL FFI and go bindings Signed-off-by: Frederico Araujo --- CHANGELOG.md | 23 +++++- Cargo.lock | 7 ++ crates/cpex-ffi/Cargo.toml | 19 +++++ crates/cpex-ffi/RELEASE.md | 9 ++ crates/cpex-ffi/src/apl.rs | 101 +++++++++++++++++++++++ crates/cpex-ffi/src/lib.rs | 98 ++++++++++++++++++---- examples/go-demo/ffi/src/cmf_plugins.rs | 2 +- examples/go-demo/ffi/src/demo_plugins.rs | 2 +- examples/go-demo/ffi/src/lib.rs | 8 +- go/cpex/abi.go | 2 +- go/cpex/apl.go | 61 ++++++++++++++ go/cpex/apl_test.go | 73 ++++++++++++++++ 12 files changed, 379 insertions(+), 26 deletions(-) create mode 100644 crates/cpex-ffi/src/apl.rs create mode 100644 go/cpex/apl.go create mode 100644 go/cpex/apl_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e6a978..cee356e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added +- APL (Attribute Policy Language) governance is now bundled into + `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers + the standard APL plugin/PDP factories (`validator/pii-scan`, + `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and + installs the APL config visitor on a manager. Call it after + `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use + `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds + the Cedarling-backed identity + PDP seams (off by default; the released + `.a` stays lean). - Publish `libcpex_ffi.a` as signed GitHub Release artifacts on every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign @@ -24,9 +33,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- consume recipe. - FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor - exposes `FFI_ABI_VERSION` (currently `1`). The Go binding checks - this in `init()` and panics on mismatch. Other language bindings - must replicate the check. + exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` + and panics on mismatch. Other language bindings must replicate the + check. + +### Changed + +- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` + extern C function and changed `cpex_load_config` to run registered + config visitors (it now calls `load_config_yaml` internally so `apl:` + blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped + in lockstep. ## [0.1.0] - 2026-05-05 diff --git a/Cargo.lock b/Cargo.lock index b921d8a7..36aebe0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,6 +811,13 @@ dependencies = [ name = "cpex-ffi" version = "0.1.0" dependencies = [ + "apl-audit-logger", + "apl-cedarling", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pii-scanner", "async-trait", "cpex-core", "rmp-serde", diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index 73d55683..8adcb8ce 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -20,6 +20,19 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] cpex-core = { path = "../cpex-core" } +# APL governance layer — bundled so Go/Python hosts can enable APL +# policies, route handlers, and the standard plugin/PDP factories via +# the `cpex_apl_install` FFI entry point. Symbols survive in the +# staticlib because that entry point references each factory. +apl-cpex = { path = "../apl-cpex" } +apl-pii-scanner = { path = "../apl-pii-scanner" } +apl-audit-logger = { path = "../apl-audit-logger" } +apl-identity-jwt = { path = "../apl-identity-jwt" } +apl-delegator-oauth = { path = "../apl-delegator-oauth" } +apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } +# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the +# default `.a` and behind the `cedarling` feature. +apl-cedarling = { path = "../apl-cedarling", optional = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -27,5 +40,11 @@ rmp-serde = { workspace = true } serde_bytes = { workspace = true } tracing = { workspace = true } +[features] +default = [] +# Opt-in Cedarling-backed identity + PDP. Build with +# `cargo build -p cpex-ffi --features cedarling`. +cedarling = ["dep:apl-cedarling"] + [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md index c52234fc..0430a34b 100644 --- a/crates/cpex-ffi/RELEASE.md +++ b/crates/cpex-ffi/RELEASE.md @@ -8,6 +8,15 @@ without needing a Rust toolchain. This document covers what is published, how to consume and verify an artifact, and the FFI ABI policy that makes the contract durable. +> **APL bundled.** The published `.a` includes the APL (Attribute Policy +> Language) governance layer and its standard plugin/PDP factories +> (`validator/pii-scan`, `audit/logger`, `identity/jwt`, +> `delegator/oauth`, `cedar-direct`). Enable it on a manager via +> `cpex_apl_install` (Go: `PluginManager.EnableAPL()`) after +> `cpex_manager_new_default` and before `cpex_load_config`. The +> Cedarling-backed seams are **not** in the default `.a` — build with +> `cargo build -p cpex-ffi --features cedarling` to include them. + ## What is published Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs new file mode 100644 index 00000000..b0cd3444 --- /dev/null +++ b/crates/cpex-ffi/src/apl.rs @@ -0,0 +1,101 @@ +// Location: ./crates/cpex-ffi/src/apl.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL (Attribute Policy Language) FFI wiring. +// +// `cpex_apl_install` registers the bundled APL plugin factories and +// installs the APL config visitor on a manager so that a subsequent +// `cpex_load_config` walks `apl:` blocks and installs per-route handlers. +// +// Registration is explicit (no inventory/ctor magic): each factory is +// referenced here so its object code survives in `libcpex_ffi.a`. Adding +// a new bundled factory means adding a `register_factory` call below. +// +// Ordering: call AFTER `cpex_manager_new_default` and BEFORE +// `cpex_load_config`. The config visitor must be registered before the +// config is loaded, and the one-shot `cpex_manager_new(yaml)` path loads +// during construction — so APL is only supported via the default-manager +// flow: +// +// cpex_manager_new_default +// → cpex_apl_install +// → cpex_load_config +// → cpex_initialize + +use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Arc; + +use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; + +/// Register the bundled APL plugin factories and install the APL config +/// visitor (in-process defaults: memory session store, default baseline +/// capabilities) on `mgr`. +/// +/// Bundled plugin factories (registered by `kind`): +/// - `validator/pii-scan` → apl-pii-scanner +/// - `audit/logger` → apl-audit-logger +/// - `identity/jwt` → apl-identity-jwt +/// - `delegator/oauth` → apl-delegator-oauth +/// +/// Bundled PDP factory (consulted for `global.apl.pdp[]` entries): +/// - `cedar-direct` → apl-pdp-cedar-direct +/// +/// With the `cedarling` cargo feature, the Cedarling-backed identity and +/// PDP seams are additionally wired. +/// +/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or +/// `RC_PANIC` if registration panicked (caught at the FFI boundary). +/// +/// # Safety +/// `mgr` must be a valid handle returned by `cpex_manager_new_default` +/// (or `cpex_manager_new`) and not yet shut down. +#[no_mangle] +pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let result = catch_unwind(AssertUnwindSafe(|| { + // Plugin factories — registered by `kind` string. Must happen + // before load_config so the manager can instantiate plugins whose + // YAML `kind:` matches. + inner.manager.register_factory( + apl_pii_scanner::KIND, + Box::new(apl_pii_scanner::PiiScannerFactory), + ); + inner.manager.register_factory( + apl_audit_logger::KIND, + Box::new(apl_audit_logger::AuditLoggerFactory), + ); + inner.manager.register_factory( + apl_identity_jwt::KIND, + Box::new(apl_identity_jwt::JwtIdentityFactory), + ); + inner.manager.register_factory( + apl_delegator_oauth::KIND, + Box::new(apl_delegator_oauth::OAuthDelegatorFactory), + ); + + // APL config visitor + PDP factories. `pdp_factories` are consulted + // for `global.apl.pdp[]` entries; cedar-direct is the bundled + // default. The visitor keeps a Weak (see + // CpexManagerInner) that upgrades during load_config_yaml. + let mut opts = apl_cpex::AplOptions::in_process(); + opts.pdp_factories = + vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + + apl_cpex::register_apl(&inner.manager, opts); + })); + + match result { + Ok(()) => RC_OK, + Err(_panic) => { + tracing::error!("cpex_apl_install: panic caught at FFI boundary"); + RC_PANIC + } + } +} diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 9c22a4b7..09d62de1 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -15,7 +15,7 @@ use std::os::raw::{c_char, c_int}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use cpex_core::context::PluginContextTable; @@ -24,6 +24,9 @@ use cpex_core::extensions::Extensions; use cpex_core::hooks::payload::PluginPayload; use cpex_core::manager::PluginManager; +// APL governance wiring — the `cpex_apl_install` extern "C" entry point. +mod apl; + // --------------------------------------------------------------------------- // FFI Result Codes // --------------------------------------------------------------------------- @@ -90,7 +93,7 @@ pub const RC_PANIC: c_int = -7; /// FFI ABI version. Bump on breaking C-surface changes; see module /// docs above for what counts as breaking. -pub const FFI_ABI_VERSION: u32 = 1; +pub const FFI_ABI_VERSION: u32 = 2; /// Returns the FFI ABI version this `libcpex_ffi` was built with. /// Language bindings call this at `init` and panic on mismatch @@ -376,7 +379,11 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin /// All managers share the process-singleton runtime returned by /// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. pub struct CpexManagerInner { - pub manager: PluginManager, + /// Held as `Arc` so the APL config visitor — registered via + /// `cpex_apl_install` — can keep a `Weak` that upgrades + /// during `load_config_yaml`. See `apl::cpex_apl_install` and + /// `apl_cpex::register_apl`. + pub manager: Arc, } /// Opaque handle to a ContextTable (Rust-owned, not serialized). @@ -475,9 +482,12 @@ pub unsafe extern "C" fn cpex_manager_new( // silently no-op. let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); - // Load config — factories must be registered separately via cpex_register_factory + // Load config — factories must be registered separately via cpex_register_factory. + // Note: this one-shot path uses `load_config` (no visitor walk), so APL is + // NOT wired here. APL requires the cpex_manager_new_default → + // cpex_apl_install → cpex_load_config flow. if let Err(e) = manager.load_config(cpex_config) { tracing::error!("cpex_manager_new: load_config failed: {}", e); return ptr::null_mut(); @@ -492,7 +502,7 @@ pub unsafe extern "C" fn cpex_manager_new( #[no_mangle] pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -523,17 +533,22 @@ pub unsafe extern "C" fn cpex_load_config( None => return RC_INVALID_INPUT, }; - let cpex_config = match cpex_core::config::parse_config(yaml) { - Ok(c) => c, - Err(e) => { - tracing::error!("cpex_load_config: config parse failed: {}", e); - return RC_PARSE_ERROR; - } - }; + // Validate first (duplicate plugin names, route shape) — preserves the + // RC_PARSE_ERROR contract. We discard the parsed value and hand the raw + // YAML to `load_config_yaml`, which re-parses into both a typed + // CpexConfig and a raw serde_yaml::Value so registered config visitors + // (e.g. the APL visitor installed by cpex_apl_install) can walk the + // `apl:` blocks and install per-route handlers. Plain `load_config` + // does NOT run that visitor walk. + if let Err(e) = cpex_core::config::parse_config(yaml) { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } - // load_config is sync (no .await), but we still wrap in catch_unwind - // so a panic in serde / config validation doesn't unwind across FFI. - let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + // load_config_yaml is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation / a visitor doesn't unwind + // across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config_yaml(yaml))); match load_result { Ok(Ok(())) => RC_OK, Ok(Err(e)) => { @@ -1093,7 +1108,7 @@ mod tests { // Touch the shared runtime so it's initialized; tests use it // rather than a per-manager runtime. let _ = shared_runtime(); - let manager = cpex_core::manager::PluginManager::default(); + let manager = Arc::new(cpex_core::manager::PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -1354,4 +1369,53 @@ mod tests { assert_eq!(cpex_is_initialized(ptr::null()), 0); } } + + #[test] + fn cpex_apl_install_rejects_null_handle() { + unsafe { + assert_eq!(crate::apl::cpex_apl_install(ptr::null()), RC_INVALID_HANDLE); + } + } + + /// Full APL flow through the FFI surface: default manager → + /// cpex_apl_install (registers bundled factories + APL visitor) → + /// cpex_load_config over an `apl:`-annotated YAML using a bundled + /// plugin kind (`audit/logger`) → cpex_initialize. Proves the visitor + /// walk runs (load uses load_config_yaml) and the bundled factory is + /// reachable, so the plugin actually instantiates. + #[test] + fn cpex_apl_install_then_load_apl_config_initializes() { + const YAML: &str = r#" +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +"#; + unsafe { + let mgr = build_test_manager(); + + assert_eq!(crate::apl::cpex_apl_install(mgr), RC_OK); + + let rc = cpex_load_config(mgr, YAML.as_ptr() as *const c_char, YAML.len() as c_int); + assert_eq!(rc, RC_OK, "load of APL config should succeed"); + + assert_eq!(cpex_initialize(mgr), RC_OK); + + // The bundled `audit/logger` factory instantiated a plugin on + // cmf.tool_pre_invoke — proves cpex_apl_install wired the kind. + assert!(cpex_plugin_count(mgr) >= 1); + let hook = "cmf.tool_pre_invoke"; + assert_eq!( + cpex_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int), + 1, + ); + + cpex_shutdown(mgr); + } + } } diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index a033576f..dd85aa49 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -265,7 +265,7 @@ impl PluginFactory for HeaderInjectorFactory { } /// Register CMF demo plugin factories on a manager. -pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_cmf_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); manager.register_factory( "builtin/cmf-header-injector", diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index f27125c9..4cbbf53c 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -268,7 +268,7 @@ impl PluginFactory for AuditLoggerFactory { } /// Register all demo plugin factories on a manager. -pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_demo_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs index 8f756f3a..8d3eb59f 100644 --- a/examples/go-demo/ffi/src/lib.rs +++ b/examples/go-demo/ffi/src/lib.rs @@ -45,12 +45,14 @@ use std::os::raw::c_int; pub unsafe extern "C" fn cpex_demo_register_factories( mgr: *mut cpex_ffi::CpexManagerInner, ) -> c_int { - let inner = match mgr.as_mut() { + let inner = match mgr.as_ref() { Some(m) => m, None => return -1, }; - demo_plugins::register_demo_factories(&mut inner.manager); - cmf_plugins::register_cmf_factories(&mut inner.manager); + // `register_factory` takes `&self`; `&inner.manager` deref-coerces + // from `Arc` to `&PluginManager`. + demo_plugins::register_demo_factories(&inner.manager); + cmf_plugins::register_cmf_factories(&inner.manager); 0 } diff --git a/go/cpex/abi.go b/go/cpex/abi.go index d5e7d374..81a14cfd 100644 --- a/go/cpex/abi.go +++ b/go/cpex/abi.go @@ -32,7 +32,7 @@ import "fmt" // expectedFFIABIVersion is the FFI_ABI_VERSION integer this binding // was generated against. Bump in lockstep with the Rust crate's // FFI_ABI_VERSION whenever the C surface changes in a breaking way. -const expectedFFIABIVersion uint32 = 1 +const expectedFFIABIVersion uint32 = 2 func init() { actual := uint32(C.cpex_ffi_abi_version()) diff --git a/go/cpex/apl.go b/go/cpex/apl.go new file mode 100644 index 00000000..04cdffa9 --- /dev/null +++ b/go/cpex/apl.go @@ -0,0 +1,61 @@ +// Location: ./go/cpex/apl.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL (Attribute Policy Language) wiring. +// +// EnableAPL registers the bundled APL plugin/PDP factories and installs +// the APL config visitor on the manager via the cpex_apl_install FFI +// entry point. Call it after NewPluginManagerDefault and before +// LoadConfig so that LoadConfig walks the config's `apl:` blocks and +// installs per-route handlers. + +package cpex + +import ( + "fmt" +) + +/* +#include + +// Opaque handle — same typedef as manager.go / ffi.go. Duplicated here +// because cgo does NOT merge declarations across files' preambles; see +// the note in manager.go. Edit all copies together if the signature +// changes. +typedef void* CpexManager; + +extern int cpex_apl_install(CpexManager mgr); +*/ +import "C" + +// EnableAPL registers the bundled APL plugin and PDP factories and +// installs the APL config visitor on the manager (in-process defaults: +// memory session store, default baseline capabilities). +// +// Bundled plugin kinds: validator/pii-scan, audit/logger, identity/jwt, +// delegator/oauth. Bundled PDP kind: cedar-direct. +// +// Ordering: call after NewPluginManagerDefault and before LoadConfig. +// The one-shot NewPluginManager(yaml) constructor loads config during +// creation and therefore does NOT support APL — use the default-manager +// flow instead: +// +// mgr, _ := NewPluginManagerDefault() +// mgr.EnableAPL() +// mgr.LoadConfig(yaml) +// mgr.Initialize() +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexPanic). +func (m *PluginManager) EnableAPL() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("EnableAPL: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_apl_install(m.handle) + return errorFromRC(int(rc), "EnableAPL") +} diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go new file mode 100644 index 00000000..ffe6892b --- /dev/null +++ b/go/cpex/apl_test.go @@ -0,0 +1,73 @@ +// Location: ./go/cpex/apl_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tests for APL wiring (EnableAPL). Run against the real Rust runtime +// via cgo; build the staticlib first: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "testing" +) + +// TestEnableAPLLoadsAplConfig drives the documented APL flow: +// NewPluginManagerDefault → EnableAPL → LoadConfig (APL-annotated) → +// Initialize. The bundled `audit/logger` factory must instantiate, so +// the cmf.tool_pre_invoke hook is registered after load. +func TestEnableAPLLoadsAplConfig(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + + yaml := ` +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +` + if err := mgr.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() < 1 { + t.Errorf("expected at least 1 plugin, got %d", mgr.PluginCount()) + } + if !mgr.HasHooksFor("cmf.tool_pre_invoke") { + t.Error("expected cmf.tool_pre_invoke hook registered after APL load") + } +} + +// TestEnableAPLAfterShutdown verifies the typed handle error is returned +// when EnableAPL is called on a shut-down manager. +func TestEnableAPLAfterShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + err = mgr.EnableAPL() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } +} From c83a6552341e1cf89b9723a658ee507daf7cb021 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 3 Jun 2026 16:45:13 -0400 Subject: [PATCH 4/4] chore: add musl tools to musl runners Signed-off-by: Frederico Araujo --- .github/workflows/release-ffi.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml index 9cafbeb7..9506d426 100644 --- a/.github/workflows/release-ffi.yaml +++ b/.github/workflows/release-ffi.yaml @@ -77,6 +77,17 @@ jobs: # subsequent releases of the same target. key: ${{ matrix.target }} + - name: Install musl toolchain + # ring (via jsonwebtoken/rustls/quinn) compiles C + asm through + # cc-rs, which for a *-unknown-linux-musl target shells out to + # -linux-musl-gcc. The runners ship only the glibc gcc, so + # we install musl-gcc here. The matrix runs each musl target on + # its native-arch runner, so musl-gcc targets the host arch and + # the CC_/LINKER env vars below redirect cc-rs and the linker to + # it. Scoped to the musl legs; gnu/darwin use their default cc. + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools musl-dev + - name: Build artifact env: TARGET: ${{ matrix.target }} @@ -84,6 +95,12 @@ jobs: # name matches the tag verbatim. VERSION: ${{ github.ref_name }} DIST_DIR: dist + # Point cc-rs and the linker at musl-gcc for the musl targets. + # No-ops for gnu/darwin (those triples don't match these keys). + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc run: bash scripts/release/build-artifact.sh - name: Upload tarball + sha256