FFI (C-ABI)
rift-ffi exposes a stable C-ABI (v2) so any language with C interop — the JVM, Node, Go, Python, … — can embed Rift in-process without shelling out to the binary. It builds as a cdylib (and an rlib for in-crate tests): crate-type = ["cdylib", "rlib"].
A cbindgen-generated header ships in the repo at crates/rift-ffi/include/rift_ffi.h (regenerate/verify with scripts/verify-ffi-cdylib.sh). Do not hand-edit it.
Handle lifecycle
The ABI is built around an opaque RiftHandle (a Tokio runtime + an Arc<ImposterManager>, plus an optional in-process admin/metrics plane).
RiftHandle* h = rift_start(); // create
// ... drive it ...
rift_stop(h); // stop and free
| Function | Signature | Purpose |
|---|---|---|
rift_start | RiftHandle* rift_start(void) | Create a handle (owns a runtime + manager). |
rift_stop | void rift_stop(RiftHandle* h) | Stop the handle’s servers and free it. |
Imposter operations
| Function | Signature | Returns |
|---|---|---|
rift_create_imposter | uint16_t rift_create_imposter(RiftHandle* h, const char* json) | The imposter port, or 0 on any error (0 is never a live port). |
rift_replace_stubs | int rift_replace_stubs(RiftHandle* h, uint16_t port, const char* json) | 0 on success, -1 on error. |
rift_delete_imposter | int rift_delete_imposter(RiftHandle* h, uint16_t port) | 0 on success, -1 on error. Returns only after the imposter is fully torn down (listener unbound, connections drained — issue #596), so an immediate re-rift_create_imposter on the same port never races the old generation. |
rift_delete_all | int rift_delete_all(RiftHandle* h) | 0 on success, -1 on error. Each imposter is torn down (as rift_delete_imposter) before this returns. |
rift_recorded | char* rift_recorded(RiftHandle* h, uint16_t port) | Recorded requests as a JSON string (caller frees with rift_free), or NULL on error. |
rift_verify | char* rift_verify(RiftHandle* h, uint16_t port, const char* body_json) | Server-side verification: given {"predicates":[…],"flowId"?,"includeRequests"?,"includeClosest"?} (the POST /verify body), returns {"matched","total","requests"?,"closest"?} as JSON (caller frees), or NULL on error. Unlike the HTTP endpoint, inject predicates are not gated — the in-process embedder is trusted. |
rift_stub_warnings | char* rift_stub_warnings(RiftHandle* h, uint16_t port) | Stub-analysis warnings (duplicate/shadowed/catch-all) as a JSON array (caller frees), or NULL on error. |
rift_apply_config | char* rift_apply_config(RiftHandle* h, const char* json) | Reconcile the full imposter set (like POST /admin/reload); returns the apply report JSON (caller frees). |
Admin long tail over FFI
The admin “long tail” — scenario/flow-state, the correlated per-space stub plane, imposter list/get, stub surgery, and scenario management — has direct C-ABI entry points, so an embedder can drive it with zero loopback HTTP (no rift_serve_admin). Each mirrors the corresponding admin-HTTP handler exactly (same ImposterManager/Imposter calls, same JSON):
| Function | Signature | Returns |
|---|---|---|
rift_flow_state_get | char* rift_flow_state_get(RiftHandle* h, uint16_t port, const char* flow_id, const char* key) | JSON envelope {"found","flowId","key","value"} (caller frees); found:false (with value:null) is an absent key, NULL is returned only on error. |
rift_flow_state_put | int rift_flow_state_put(RiftHandle* h, uint16_t port, const char* flow_id, const char* key, const char* value_json) | 0 on success, -1 on error. value_json is the bare JSON value. |
rift_flow_state_delete | int rift_flow_state_delete(RiftHandle* h, uint16_t port, const char* flow_id, const char* key) | 0 on success, -1 on error. |
rift_space_add_stub | int rift_space_add_stub(RiftHandle* h, uint16_t port, const char* flow_id, const char* stub_json) | 0 on success, -1 on error. The stub’s space is set from flow_id. |
rift_space_list_stubs | char* rift_space_list_stubs(RiftHandle* h, uint16_t port, const char* flow_id) | JSON {"space","stubs":[…]} (caller frees), or NULL on error. |
rift_space_delete | int rift_space_delete(RiftHandle* h, uint16_t port, const char* flow_id) | 0 on success, -1 on error. One-call per-space teardown (scoped stubs + recorded + scenario state). |
rift_space_recorded | char* rift_space_recorded(RiftHandle* h, uint16_t port, const char* flow_id) | The requests recorded for that space (header-filtered received) as a JSON array (caller frees), or NULL on error. |
rift_flow_state_get never conflates “absent” with “failed”: an absent key returns the envelope with found:false (a normal outcome, rift_last_error untouched), and NULL is reserved strictly for a genuine error — so a consumer treats found:false as “unset” and NULL as a read failure, with no need to parse rift_last_error.
The rest of the admin long tail — imposter list/get, stub surgery (add/get/update/delete, by index or id), clearing recorded/proxy recordings, enable/disable, and scenario list/set-state/reset — is likewise direct C-ABI, each calling the same ImposterManager/Imposter method the corresponding admin-HTTP handler calls:
| Function | Signature | Returns |
|---|---|---|
rift_list_imposters | char* rift_list_imposters(RiftHandle* h, const char* options_json) | JSON {"imposters":[...]} (caller frees), or NULL on error. options_json (NULL = defaults): {"replayable":false,"removeProxies":false}. replayable returns full ImposterConfigs (the same removeProxies projection the admin ?replayable=true route serves); otherwise a summary shape {"protocol","port","name"?,"numberOfRequests","enabled"} per imposter (imposters with no assigned port are skipped). |
rift_get_imposter | char* rift_get_imposter(RiftHandle* h, uint16_t port, const char* options_json) | Same options_json shape as rift_list_imposters. Replayable returns the single ImposterConfig; otherwise a detail object {"protocol","port","name"?,"numberOfRequests","enabled","recordRequests","stubs","requests"} (caller frees), or NULL on error. |
rift_add_stub | int rift_add_stub(RiftHandle* h, uint16_t port, const char* stub_json, int32_t index) | 0 on success, -1 on error. index < 0 appends; otherwise inserts at that position. No stub id is auto-generated; no injection gating (the direct C-ABI is the trusted embedder, like rift_replace_stubs). |
rift_get_stub | char* rift_get_stub(RiftHandle* h, uint16_t port, const char* ref_json) | The bare Stub JSON (caller frees), or NULL on error (out-of-range index, unknown id, or malformed ref). ref_json is {"index":N} or {"id":"..."}. |
rift_update_stub | int rift_update_stub(RiftHandle* h, uint16_t port, const char* ref_json, const char* stub_json) | 0 on success, -1 on error. Replaces the stub addressed by ref_json with stub_json. |
rift_delete_stub | int rift_delete_stub(RiftHandle* h, uint16_t port, const char* ref_json) | 0 on success, -1 on error. |
rift_clear_recorded | int rift_clear_recorded(RiftHandle* h, uint16_t port) | 0 on success, -1 on error. Clears all recorded requests for the imposter. |
rift_clear_proxy_recordings | int rift_clear_proxy_recordings(RiftHandle* h, uint16_t port) | 0 on success, -1 on error. Clears saved proxy responses. |
rift_set_imposter_enabled | int rift_set_imposter_enabled(RiftHandle* h, uint16_t port, int32_t enabled) | 0 on success, -1 on error. enabled != 0 enables; 0 disables. |
rift_scenarios | char* rift_scenarios(RiftHandle* h, uint16_t port, const char* flow_id) | JSON {"flowId","scenarios":[{"name","state"}]} (caller frees), or NULL on error. flow_id may be NULL for the imposter’s default flow. |
rift_set_scenario_state | int rift_set_scenario_state(RiftHandle* h, uint16_t port, const char* name, const char* state_json) | 0 on success, -1 on error (including a missing state field). state_json: {"state":"...","flowId":"..."?} (flowId optional → default flow). |
rift_reset_scenarios | int rift_reset_scenarios(RiftHandle* h, uint16_t port, const char* flow_id) | 0 on success, -1 on error. Resets every scenario for flow_id (NULL → default flow) back to its initial state. |
Errors set rift_last_error like the data-plane functions. Together with the data plane (rift_create_imposter/rift_replace_stubs/rift_recorded/rift_delete_imposter), these cover the whole SPI over C-ABI — an embedded consumer needs no admin HTTP server and no loopback client.
In-process admin plane (optional)
rift_serve_admin starts the real admin API (and, if metricsPort is set, the metrics server) on the handle’s runtime, serving the handle’s manager — so external tooling can talk to an embedded Rift over HTTP. It is optional: the direct C-ABI above already covers the admin long tail; use rift_serve_admin only when you want an actual HTTP admin surface.
char* result = rift_serve_admin(h, "{\"port\":0}");
// result: {"adminPort":49321,"adminUrl":"http://127.0.0.1:49321","metricsPort":null}
rift_free(result);
- Options JSON (pass
NULLor{}for all defaults; every field optional):{"host":"127.0.0.1","port":0,"apiKey":null,"metricsPort":null,"configFile":null,"noParse":false,"config":null,"allowInjection":false,"requireAdminAuth":false,"upstreamCaFile":null,"upstreamCaPem":null,"upstreamTlsSkipVerify":false}.port: 0binds an ephemeral port;hostmust be an IP literal — IPv4, or IPv6 bare (::1) or bracketed ([::1]), a numeric scope id (fe80::1%2) kept — and a DNS name such aslocalhostis refused (#1137; before that fix a bare IPv6 host could never bind). The metrics server, if asked for, binds the same IP and the same scope id (#1150).configFileis loaded as the reload source (like--configfile);configis an inline{"imposters":[...]}.configFileandconfigdo not compose — pass one. Since 0.17.0 an unknown key is a hard error naming the key (NULL+rift_last_error), not a silent drop — so a typo fails loudly instead of leaving the option quietly inert. apiKey: a blank string is rejected —rift_serve_adminreturnsNULLand records the reason inrift_last_error. A blank key would enable the admin auth gate and then authenticate every request, and the realistic way to send one is plumbing rather than intent (apiKey(getProperty("rift.apikey", "")), a config value that renders empty). Pass a real token, or omit the field to leave the admin plane unauthenticated.-
noParse(defaultfalse, issue #1107): loadconfigFileverbatim, skipping EJS preprocessing, as--no-parsedoes for--configfile. A tag the loader does not evaluate — a literal<%in a body or predicate included — otherwise fails the serve (NULL+rift_last_error) naming the tag, and there is no per-tag escape.POST /admin/reloadre-reads the file with the same setting.noParse: truewithoutconfigFileis refused, since it would change nothing.Feature-detect it — see Detecting which options an engine accepts below. An engine from 0.17.0 on that does not list
"noParse"refuses the key as unknown. An older engine publishes noserveOptions, ignores the key, and strips or blanks the tag with only a log line, so check the list rather than relying on an error. -
requireAdminAuth(defaultfalse, issue #863): refuse to serve when the admin plane would bind a non-loopback address with noapiKey, instead of logging a warning.hostdefaults to127.0.0.1for this door, so the check is silent unless you ask for an off-host bind. It gates on authentication, not on the address — a realapiKeysatisfies it on any bind. Under a refusalrift_serve_adminreturnsNULLwith the reason inrift_last_error, and nothing has been bound.Since issue #1149 it also covers the intercept listener: after this call,
rift_start_interceptand aPOST /intercepton the embedded admin plane refuse an off-host bind with noauthinstead of only warning. Callrift_serve_adminfirst — the policy is whatever the most recent serve stated, and a serve that omits the key returns the handle to warn-only. If an exposed listener is already running when you serve withrequireAdminAuth, the serve is refused naming that listener rather than reporting a strictness it cannot deliver; the listener is left running for you to stop or re-start withauth.Feature-detect it — see Detecting which options an engine accepts below. Against an engine released before 0.17.0 this field is silently ignored and the keyless off-host admin plane is served anyway, with a normal
{"adminPort":…}and nothing to catch.The default
Warnposture writes through thetracingfacade.rift-ffiinstalls no subscriber — correct for a library, but it means an embedding host that has not installed one of its own sees nothing at all. Install atracingsubscriber if you want the warning to be visible. upstreamCaFile/upstreamCaPem(defaultnull): an extra trust anchor for every outbound TLS connection this engine makes —proxystub upstreams, anhttps://configFile, and the intercept listener’s WebSocket relay. A path or the PEM text; supplying both is refused, since an embedder setting both has not decided which is authoritative. Appended to the OS trust store, not replacing it. Read and checked during this call, so a bad anchor fails here rather than surfacing later as a per-request proxy error.-
upstreamTlsSkipVerify(defaultfalse): accept any upstream certificate. Development only; it logs a warning whenever a client is built with it.The relay reads the trust when the listener binds, so start the intercept listener after the
rift_serve_adminthat sets it. -
allowInjection(defaultfalse): whether Rift admits script/injectimposters (inject/decorate/shellTransform/JS-functionwait/_rift.script), mirroring the--allowInjectionCLI flag. Leave itfalseunless you intend to permit them.What the gate protects.
allowInjectiongates config documents that cross a trust boundary, not untrusted hosts. A config the embedding host hands over in-process is trusted; one that arrives over HTTP or is read off disk is not:Door Gated by allowInjection?Why Admin plane ( POST /imposters, …)yes (#492) Arrives over HTTP, possibly from a less-trusted caller configFileyes (#616) A path dereferenced to disk content the host may not have authored — ops-provisioned, mounted, or edited out-of-band. Same class as the CLI’s --configfile/--datadirPOST /admin/reload(re-readsconfigFile)yes (#612) Same file, same flag — a file edited to add scripting after a clean start is refused on reload Inline configno (#492) Authored in-process by the host rift_create_imposter,rift_replace_stubs,rift_apply_config, …no (#492) Authored in-process by the host The in-process doors are ungated by design, not an oversight: a caller who can reach the C-ABI can already execute code in this process, so gating its own JSON would restrict nobody while breaking hosts that legitimately drive script imposters over the C-ABI. If you load config from a file you do not fully control, keep
allowInjection: false— a scriptedconfigFilethen fails the serve outright (NULL+rift_last_error) rather than loading, so nothing is applied. A behaviors block the engine cannot read as an object (for example an array_behaviors) is refused at every door, gated or not, before the gate runs (#1101). -
Returns (caller frees):
{"adminPort":...,"adminUrl":"...","metricsPort":...}, orNULLon error (bad JSON, bind failure, or already serving — one admin plane per handle).adminUrlis built from the bound address, so an IPv6 bind readshttp://[::1]:49321.For a scoped bind (
fe80::1%2)adminUrl— andinterceptUrlonrift_start_intercept— carry the socket-address spelling,http://[fe80::1%2]:49321, which is not a portable URL. No spelling is: Go’surl.Parserejects the raw%, Java’sURIaccepts it and rejects the RFC 6874%25form (it reads the zone as literal digits and connects to the wrong scope), and the WHATWG parser Node uses rejects a zone id either way. RFC 6874 has itself since been obsoleted (RFC 9844) for this reason. The engine therefore leaves the field as std’s spelling rather than picking a form that breaks a different runtime. For a scoped bind, build the URL fromadminPort/interceptPortplus the host you passed in, which you already have.The
rift saveandrift healthchecksubcommands cannot reach a scoped host at all: they go throughreqwest, whose URL parser rejects a zone in either spelling.
Detecting which options an engine accepts
rift_build_info() publishes the accepted option keys as serveOptions (issue #877), so an SDK can check before sending one:
{"version":"…","commit":"…","builtAt":"…","features":["javascript"],
"serveOptions":["host","port","apiKey","metricsPort","configFile","noParse","config",
"allowInjection","requireAdminAuth","upstreamCaFile","upstreamCaPem",
"upstreamTlsSkipVerify"]}
0.17.0 published the first eight keys without noParse; noParse and the three upstream* keys (outbound TLS trust, #974) arrived after it. Check for the specific key you are about to send.
Absence of the key is the signal. An engine older than 0.17.0 reports no serveOptions at all — treat that as “no serve option can be relied upon” and fall back to the behaviour you would have had without the option. This is the only check that works against already-released engines, and it is why you must not feature-detect by sending the option and watching for an error:
- an engine older than 0.17.0 accepts the JSON, ignores the key, and returns success — there is no error to catch, and for
requireAdminAuthspecifically the silent outcome is fail-open; - an engine 0.17.0 or newer does reject an unknown key, but relying on that means probing by deliberately provoking a failure, and it still tells you nothing about the older engines.
serveOptions is deliberately separate from features, which lists compiled cargo features (redis-backend, javascript) — different question, different array. The same list is published over HTTP at GET /config for consumers that do not link the C-ABI.
Intercept proxy over FFI
Start the intercept/TLS-MITM proxy on the handle and drive its whole control plane over C-ABI — no in-process admin HTTP needed. One intercept listener per handle; rift_stop_intercept stops it, and rift_stop shuts it down with the handle.
| Function | Signature | Returns |
|---|---|---|
rift_start_intercept | char* rift_start_intercept(RiftHandle* h, const char* options_json) | JSON {"interceptPort","interceptUrl"} (caller frees), or NULL on error (bad JSON, bind failure, half-configured CA pair, both CA pairs supplied, CA load failure, already started). options_json: {"host":"127.0.0.1","port":0,"caCertPath":null,"caKeyPath":null,"caCertPem":null,"caKeyPem":null,"returnCaKey":false,"auth":null,"rules":[]} (port 0 = OS-assigned; host an IPv4 or IPv6 literal, bare or bracketed); NULL/{} for defaults. rules (issue #655) installs rules — the /intercept/rules shape — before the listener accepts a connection, so no traffic races a follow-up rift_intercept_add_rules; exceeding the rule-store capacity fails the start. Supply the CA as files (caCertPath/caKeyPath) or inline PEM bytes (caCertPem/caKeyPem, issue #593 — each pair both-or-neither, mutually exclusive). "returnCaKey":true (only with no CA source) mints a fresh CA and adds "caCertPem"/"caKeyPem" to the response once — CA private-key material, treat as secret. |
rift_stop_intercept | int rift_stop_intercept(RiftHandle* h) | 0 on success (including the idempotent nothing-running case), -1 only on a null handle / caught panic. Stops the listener, releases its port, and drops its rules + CA — parity with DELETE /intercept. A later rift_start_intercept without CA paths mints a fresh CA. |
rift_intercept_add_rules | int rift_intercept_add_rules(RiftHandle* h, const char* rules_json) | 0/-1. One rule (object) or many (array), same shape as /intercept/rules. |
rift_intercept_list_rules | char* rift_intercept_list_rules(RiftHandle* h) | The current rules as a JSON array (caller frees), or NULL on error. |
rift_intercept_clear_rules | int rift_intercept_clear_rules(RiftHandle* h) | 0/-1. |
rift_intercept_ca_pem | char* rift_intercept_ca_pem(RiftHandle* h) | The CA cert PEM (caller frees), or NULL on error. |
rift_intercept_export_truststore | int rift_intercept_export_truststore(RiftHandle* h, const char* format, const char* password, const char* out_path) | Writes a truststore to out_path (format = "pkcs12"/"jks", password may be NULL → "changeit"). 0/-1. A truststore is binary, so it is written to a file — the form a JVM trustStore consumes directly. |
An embedder calls rift_start_intercept, reads interceptPort, adds rules and fetches the CA / truststore (all over FFI), then points a CA-trusting SUT’s HTTPS proxy at interceptPort — with no loopback HTTP. Forward { port } rules reach any imposter on that localhost port, including FFI-created ones. Errors set rift_last_error. A handle that never calls rift_start_intercept is unaffected.
The intercept listener and the handle’s embedded admin plane share one slot (#493): if you also call rift_serve_admin, its /intercept* routes operate on the same listener the C-ABI functions drive. rift_start_intercept then GET /intercept reports it; POST /intercept feeds rift_intercept_add_rules; and a double-start across the two surfaces conflicts consistently (409 / -1). (Previously rift_serve_admin served no /intercept routes at all.)
By default (no CA source) rift_start_intercept generates a fresh ephemeral intercept CA, unchanged from earlier releases. As of v0.11.3 (#429), passing both caCertPath and caKeyPath (PEM file paths) loads that committed CA instead — letting independent embedded instances share one trust anchor rather than each minting its own. Passing only one of the pair is a hard error (both or neither).
Since issue #593 the CA may instead be supplied inline as caCertPem/caKeyPem (the PEM bytes, not paths) — the same both-or-neither rule, and mutually exclusive with the path pair — so a remote or containerized engine can be handed a CA over the C-ABI without staging a file. And "returnCaKey":true (valid only when no CA source is given) has the engine mint a fresh CA and return its cert and key once in the response (caCertPem/caKeyPem), for a bootstrap flow: start with returnCaKey, persist the pair, then supply it back via caCertPem/caKeyPem on later starts. The returned caKeyPem is CA private-key material — treat it as secret. Combining returnCaKey with any supplied CA source is a hard error.
auth (issue #878) requires Proxy-Authorization: Basic <base64(user:pass)> on every CONNECT: {"auth":{"username":"ci","password":"s3cr3t"}}. Omit it and the proxy is open, which is what it has always been. A blank username or password is rejected at start — a blank secret would switch the gate on and then admit everyone. Bear in mind what this listener is: a TLS-MITM proxy serving certificates forged by a CA your clients are asked to trust, so an unauthenticated one reachable off-host is worth more care than an unauthenticated admin plane, not less.
Unlike rift_serve_admin’s options (see #877), this struct has always used deny_unknown_fields — so sending auth to an engine that predates it is a hard error naming the field, not a silent ignore. That makes it deterministically detectable; state the required engine release (0.17.0+) in any SDK that offers it.
interceptUrl/interceptPort in the response are derived from the listener’s actual bound address (v0.11.2, #425/#426) — not hardcoded to 127.0.0.1. A loopback host (the default) reports loopback as before; a 0.0.0.0 (or other non-loopback) bind surfaces that address verbatim, so dial a concrete interface rather than assuming 127.0.0.1.
Build identity
rift_build_info is a static JSON string (never freed) — probe it to detect a v2 library and read which engines are compiled in:
const char* info = rift_build_info();
// {"version":"<release>","commit":"<sha>|null","builtAt":"<iso8601>|null",
// "features":["redis-backend","javascript"],"serveOptions":[...]}
// Do NOT call rift_free on this pointer.
commit/builtAt are null unless stamped at build time (via build.rs). serveOptions (0.17.0+) is described in Detecting which options an engine accepts.
ABI contract version
rift_abi_version returns the C-ABI contract version as a uint32_t — the runtime-queryable form of the abi field in ffi-manifest.json. Same contract, two encodings: the manifest carries the version-tagged string "v2", the symbol returns the bare integer 2, so map between them rather than string-comparing:
uint32_t v = rift_abi_version(); // 2 (the "v2" contract in ffi-manifest.json)
It is bumped only on a breaking ABI change (a symbol removed, a signature changed, or a documented semantic break) — releases that merely add symbols or fix bugs keep the number, and new symbols are discovered by presence rather than a version floor. This is what an SDK should gate on: unlike rift_build_info’s version (the release/marketing string, which reads as the workspace placeholder 0.1.0 on a locally-built engine), it tracks the C-ABI itself, so a compatibility check never misfires on a dev or vendored build. Adopt it behind feature detection — if the symbol is present, gate on it; if absent (an older cdylib), fall back to probing the symbol set.
Error handling & ownership conventions
- Error signaling. Every operation returns a sentinel on failure —
0(rift_create_imposter),-1(theint-returning ops), orNULL(the string-returning ops). rift_last_error.char* rift_last_error(void)returns the last error message for the current thread (caller frees), orNULLif none. Every operation entry clears the thread-local error first and sets it on failure, so read it immediately after a sentinel return.- Panic safety. A Rust panic inside any operation is caught at the boundary and turned into that function’s sentinel plus a
rift_last_errormessage (never unwinds across the C ABI, never crashes or wedges the host runtime) — so an engine bug degrades to a normal error return you handle exactly like any other failure. - String ownership. Every
char*the ABI returns must be released withvoid rift_free(char* p)— exceptrift_build_info’s pointer, which is static and must not be freed.rift_free(NULL)is a safe no-op. - Handle ownership. Free a handle exactly once with
rift_stop.
Cargo features
rift-ffi forwards engine features rather than hard-coding them, so a per-platform build can drop engines it doesn’t need: default = ["redis-backend", "javascript", "quamina-matching"]. (rift_build_info().features reports only redis-backend and javascript.) The mimalloc allocator feature is deliberately never forwarded — a cdylib must not impose a global allocator on its host process.
# Full-featured cdylib (default)
cargo build -p rift-ffi --release
# Minimal cdylib — no scripting engine, no Redis, no Quamina prefilter
cargo build -p rift-ffi --release --no-default-features
Prebuilt cdylibs & the release manifest
Every release ships the librift_ffi cdylib as a standalone, classifier-named asset per platform (librift_ffi-<classifier>.{so,dylib,dll}, e.g. librift_ffi-linux-x86_64.so, librift_ffi-darwin-aarch64.dylib, librift_ffi-windows-x86_64.dll) alongside a matching .sha256. The x86_64 musl target ships a cdylib too (librift_ffi-linux-x86_64-musl.so) — built with crt-static disabled so the .so links dynamically against Alpine’s musl libc (the platform’s static binaries stay statically linked), for embedded SDK tests on Alpine CI images.
Each cdylib is self-contained: it links only stock-host system libraries (the C runtime and, on macOS, system frameworks) — no third-party native dependency to install. A release gate asserts this per platform (scripts/check-ffi-selfcontained.sh, issue #469), so a consumer can dlopen the library on a bare host without extra packages.
To let SDK consumers (rift-java’s natives packaging, rift-go’s fetcher/loader, spawn-transport binary downloads) resolve these assets without hardcoding release-URL patterns, each release also publishes ffi-manifest.json — a platform → asset map generated by scripts/gen-ffi-manifest.sh from the per-platform .sha256 assets:
{
"version": "v0.12.0",
"abi": "v2",
"artifacts": [
{
"platform": "linux-x86_64",
"file": "librift_ffi-linux-x86_64.so",
"sha256": "…",
"url": "https://github.com/achird-labs/rift/releases/download/v0.12.0/librift_ffi-linux-x86_64.so"
}
]
}
Fetch it at https://github.com/achird-labs/rift/releases/download/<version>/ffi-manifest.json, pick the artifacts[] entry matching the host’s platform, download url, and verify against sha256. abi is the C-ABI major version (v2) the cdylibs export — the same contract a loaded library reports at runtime via rift_abi_version.