ffi-bridge
Memory-safe Go↔Rust FFI boundary helpers: buffer management, error propagation, panic safety, and named callback registration.
A dual-language package — a Go module and a Rust crate — that provides safe abstractions for crossing the Go↔Rust FFI boundary. Extracted from a hybrid-runtime blockchain engine where correctness at the FFI layer is critical.
Why ffi-bridge?
FFI between Go and Rust is notoriously error-prone:
| Problem | ffi-bridge solution |
|---|---|
| Dangling pointers | Explicit ownership: Rust allocates, Rust frees via exported functions |
| GC interference | runtime.SetFinalizer isolates Go GC from Rust heap allocations |
| Panic unwinds crossing ABI | Every extern "C" fn wraps its body in catch_panic |
| Error propagation loss | FfiResult carries typed error codes + messages over the boundary |
| Callback lifetime bugs | Named callback registry with Mutex-guard and once_cell |
Repository Layout
ffi-bridge/
├── go/ # Go module (github.com/ChainPrimitives/ffi-bridge)
│ ├── go.mod
│ ├── bridge.go # Buffer type + FromBytes / FromJSON
│ ├── memory.go # Low-level alloc helpers, FfiString conversion
│ ├── errors.go # ErrorCode, FfiError, CheckResult
│ ├── types.go # Type mirrors + Version()
│ ├── callback.go # InvokeCallback / InvokeCallbackJSON
│ ├── bridge_test.go # Unit tests + benchmarks
│ └── examples/
│ └── basic/main.go
├── rust/ # Rust crate (ffi-bridge on crates.io)
│ ├── Cargo.toml
│ ├── src/
│ │ ├── lib.rs
│ │ ├── memory.rs # FfiBuffer, FfiString
│ │ ├── errors.rs # FfiErrorCode, FfiError, FfiResult, catch_panic
│ │ ├── types.rs # BridgeValue, utilities
│ │ ├── bridge.rs # BridgeCall, ffi_echo, ffi_version
│ │ └── callback.rs # Named callback registry
│ ├── tests/
│ │ └── integration.rs
│ └── examples/
│ └── basic.rs
├── shared/
│ ├── ffi.h # C header — the ABI contract
│ └── error_codes.h # Standalone error code definitions
├── Makefile
├── LICENSE
└── README.md
Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Rust | ≥ 1.70 | Build the Rust crate |
| Go | ≥ 1.21 | Build the Go module |
| C compiler | Any (clang/gcc) | CGo linkage |
cargo |
Latest stable | Rust build system |
Getting Started
1. Clone and build
2. Use the Rust crate
Add to Cargo.toml:
[]
= "1.0"
3. Use the Go module
Note: CGO_ENABLED=1 is required. The Rust shared library must be built and accessible via
LD_LIBRARY_PATH(Linux) orDYLD_LIBRARY_PATH(macOS) at link time.
Rust API
Buffer allocation
use ;
// Allocate a buffer
let buf = new;
// From a Vec<u8> (zero-copy, transfers ownership)
let buf = from_vec;
// From a JSON-serializable value
let buf = from_json?;
// Read as a slice
let slice = unsafe ;
// Deserialize
let value: MyType = unsafe ?;
// Free (must be called when done — no Drop)
ffi_buffer_free;
FfiResult
use ;
// Constructors
let ok_result = ok;
let err_result = err;
// Panic-safe wrapper
let result = catch_panic;
Callbacks
use ;
// Register from Rust
register_callback?;
BridgeCall
use BridgeCall;
pub extern "C"
Go API
Buffer operations
import ffibridge "github.com/ChainPrimitives/ffi-bridge"
// From bytes
buf := ffibridge.FromBytes([]byte("hello rust"))
defer buf.Free()
// From a JSON-serializable value
buf, err := ffibridge.FromJSON(myStruct)
if err != nil
defer buf.Free()
// Read back
bytes := buf.Bytes()
// Decode JSON
var resp MyResponse
if err := buf.ToJSON(&resp); err != nil
Invoking Rust callbacks
// Raw buffer
result, err := ffibridge.InvokeCallback("my.handler", inputBuf)
if err != nil
defer result.Free()
// JSON convenience (serialize in → deserialize out)
var resp MyResponse
err := ffibridge.InvokeCallbackJSON("my.handler", myRequest, &resp)
Errors
// FfiError carries a typed error code + message
result, err := ffibridge.InvokeCallback("unknown.callback", buf)
if err != nil
Version check
ver := ffibridge.Version() // e.g. "1.0.0"
C Header
The shared C ABI contract lives in shared/ffi.h. Include it in any C, C++, or CGo consumer:
FfiBuffer buf = ;
// ... write data into buf.data, set buf.len ...
;
FfiResult result = ;
if
;
Safety Guarantees
Memory safety
- Rust allocates, Rust frees. Go never calls
malloc/freefor FFI buffers. All allocation goes through Rust's global allocator. - GC isolation. Go's garbage collector cannot see Rust heap memory.
runtime.SetFinalizeron the Go*Bufferwrapper ensures cleanup ifFree()is not called explicitly. - No double-free.
Buffer.Free()is idempotent.CheckResultzeroes the payload field ofFfiResultbefore callingffi_result_freeto prevent the Rust side from freeing bytes that have already been copied to Go.
Panic safety
- Panics never cross the FFI boundary. Every
extern "C"function in the Rust crate wraps its body incatch_panic, which usesstd::panic::catch_unwind. A caught panic is converted toFfiResult { error_code: FFI_ERR_PANIC, ... }.
Thread safety
- Callback registry is protected by
std::sync::Mutex<HashMap>initialized withonce_cell::sync::Lazy. Registrations from any thread are safe. - Poisoned locks are detected and reported as
FFI_ERR_LOCK_POISONED.
Error Codes
| Code | Value | Description |
|---|---|---|
FFI_OK |
0 | Success |
FFI_ERR_NULL_POINTER |
1 | Required pointer was null |
FFI_ERR_BUFFER_TOO_SMALL |
2 | Buffer smaller than required |
FFI_ERR_INVALID_UTF8 |
3 | Input is not valid UTF-8 |
FFI_ERR_SERIALIZATION |
4 | JSON serialization failed |
FFI_ERR_PANIC |
5 | Rust panic caught at boundary |
FFI_ERR_TIMEOUT |
6 | Operation timed out |
FFI_ERR_NOT_FOUND |
7 | Named resource not found |
FFI_ERR_LOCK_POISONED |
8 | Mutex lock poisoned |
FFI_ERR_UNKNOWN |
99 | Unclassified error |
Error codes are stable — values are never renumbered or removed.
Build Details
Makefile targets
CGo link flags
The Go module uses #cgo LDFLAGS to locate the Rust shared library:
-L${SRCDIR}/../rust/target/release -lffi_bridge
For production deployments, install the library system-wide or set
CGO_LDFLAGS / LD_LIBRARY_PATH appropriately.
Platform support
| Platform | Rust target | Library extension |
|---|---|---|
| Linux x86_64 | x86_64-unknown-linux-gnu |
.so |
| macOS arm64 | aarch64-apple-darwin |
.dylib |
| macOS x86_64 | x86_64-apple-darwin |
.dylib |
Testing Strategy
- Unit tests in each Rust module (
#[cfg(test)]) — buffer alloc/free, JSON round-trips, error enum mapping, panic catching. - Integration tests in
rust/tests/integration.rs— full FFI surface area end-to-end. - Go tests in
go/bridge_test.go— buffer lifecycle, JSON helpers, error types, benchmarks. - Memory safety — run tests under AddressSanitizer (Linux):
&& RUSTFLAGS="-Z sanitizer=address" - Valgrind (Linux):
&& CGO_ENABLED=1
Publishing
# Rust crate
&&
# Go module — tag follows the go/ prefix convention for multi-module repos
Or use make publish which runs tests first.
Development
Contributing
Contributions are welcome! This project follows standard open-source practices.
Getting Started
- Fork the repository
- Clone your fork:
- Branch for your change:
- Test your changes:
&& - Commit using Conventional Commits:
feat: add FfiString length validation fix: prevent double-free in CheckResult on empty payload docs: expand safety guarantee documentation - Open a Pull Request against
main.
Guidelines
- Tests required. New functionality must include unit tests.
- No unsafe without justification. All
unsafeblocks must have a// SAFETY:comment. - No breaking changes to
FfiErrorCodevalues or exported C symbols. - Keep FFI boundary minimal. Only
repr(C)POD types may cross the boundary. - For security vulnerabilities, email subaskar.sr@gmail.com directly.
Changelog
v1.0.0
- 🚀 Initial release
FfiBuffer— explicit-ownership heap buffer with alloc/freeFfiString— UTF-8 string allocationFfiResult— C-ABI result type with error code + messagecatch_panic— panic boundary guardBridgeCall— high-level FFI call builder with JSON helpers- Named callback registry with thread-safe global state
- Go module:
Buffer,FromBytes,FromJSON,InvokeCallback,InvokeCallbackJSON,CheckResult - Shared C header (
shared/ffi.h,shared/error_codes.h) - Full test suite: Rust unit + integration tests, Go unit tests + benchmarks
- Makefile for build orchestration
License
MIT © 2026 Subaskar Sivakumar