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,"config":null,"allowInjection":false}.port: 0binds an ephemeral port;configFileis loaded as the reload source (like--configfile);configis an inline{"imposters":[...]}.configFileandconfigdo not compose — pass one. -
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. - Returns (caller frees):
{"adminPort":...,"adminUrl":"...","metricsPort":...}, orNULLon error (bad JSON, bind failure, or already serving — one admin plane per handle).
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} (port 0 = OS-assigned); NULL/{} for defaults. 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 — RFC-003 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.
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":"0.11.3","commit":"<sha>|null","builtAt":"<iso8601>|null","features":["redis-backend","javascript"]}
// Do NOT call rift_free on this pointer.
commit/builtAt are null unless stamped at build time (via build.rs).
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"]. 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 engines, no Redis
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.