From 504b74392915bb6584bb05934b67a8a8db6d04bf Mon Sep 17 00:00:00 2001 From: osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:38:38 +0100 Subject: [PATCH] wip --- Cargo.lock | 16 + Cargo.toml | 2 + crates/c-test/Cargo.toml | 13 + crates/c-test/build.rs | 17 + crates/c-test/c/alice_bob_test.c | 206 ++++++++++ crates/c-test/c/delivery.c | 88 ++++ crates/c-test/src/lib.rs | 86 ++++ crates/client-ffi/Cargo.toml | 11 + crates/client-ffi/client_ffi.h | 207 ++++++++++ crates/client-ffi/src/api.rs | 399 +++++++++++++++++++ crates/client-ffi/src/delivery.rs | 40 ++ crates/client-ffi/src/lib.rs | 5 + crates/client/src/client.rs | 5 - crates/client/src/platforms/test/delivery.rs | 28 +- crates/client/src/platforms/test/mod.rs | 2 +- crates/client/tests/alice_and_bob.rs | 26 +- 16 files changed, 1129 insertions(+), 22 deletions(-) create mode 100644 crates/c-test/Cargo.toml create mode 100644 crates/c-test/build.rs create mode 100644 crates/c-test/c/alice_bob_test.c create mode 100644 crates/c-test/c/delivery.c create mode 100644 crates/c-test/src/lib.rs create mode 100644 crates/client-ffi/Cargo.toml create mode 100644 crates/client-ffi/client_ffi.h create mode 100644 crates/client-ffi/src/api.rs create mode 100644 crates/client-ffi/src/delivery.rs create mode 100644 crates/client-ffi/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 73f6810..2f85301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,14 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "c-test" +version = "0.1.0" +dependencies = [ + "cc", + "client-ffi", +] + [[package]] name = "cc" version = "1.2.54" @@ -127,6 +135,14 @@ dependencies = [ "tempfile", ] +[[package]] +name = "client-ffi" +version = "0.1.0" +dependencies = [ + "client", + "libchat", +] + [[package]] name = "const-oid" version = "0.9.6" diff --git a/Cargo.toml b/Cargo.toml index ca37bad..846f0c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ members = [ "core/double-ratchets", "core/storage", "crates/client", + "crates/client-ffi", + "crates/c-test", ] [workspace.dependencies] diff --git a/crates/c-test/Cargo.toml b/crates/c-test/Cargo.toml new file mode 100644 index 0000000..036c9b8 --- /dev/null +++ b/crates/c-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "c-test" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] + +[dependencies] +client-ffi = { path = "../client-ffi" } + +[build-dependencies] +cc = "1" diff --git a/crates/c-test/build.rs b/crates/c-test/build.rs new file mode 100644 index 0000000..594ce61 --- /dev/null +++ b/crates/c-test/build.rs @@ -0,0 +1,17 @@ +fn main() { + let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let header_dir = manifest_dir.parent().unwrap().join("client-ffi"); + + // alice_bob_test.c contains both the delivery implementation (queue + + // callback) and the test logic. It has no direct references to Rust + // symbols; all protocol calls go through the ClientOps vtable supplied + // by the Rust harness. + cc::Build::new() + .file("c/alice_bob_test.c") + .include(&header_dir) + .compile("c_alice_bob"); + + println!("cargo:rerun-if-changed=c/alice_bob_test.c"); + println!("cargo:rerun-if-changed=c/delivery.c"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/c-test/c/alice_bob_test.c b/crates/c-test/c/alice_bob_test.c new file mode 100644 index 0000000..9ede8b2 --- /dev/null +++ b/crates/c-test/c/alice_bob_test.c @@ -0,0 +1,206 @@ +/* + * c-test platform: Alice-Bob message exchange written entirely in C. + * + * Delivery (queue + callback) is implemented here in C. + * Protocol operations are called through a ClientOps vtable supplied by + * the Rust test harness. This keeps the C archive free of direct references + * to Rust symbols, avoiding linker-ordering issues. + * Persistence is in-memory ephemeral (same as the Rust TestPlatform). + */ + +#include "client_ffi.h" + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ + * In-memory delivery queue + * ------------------------------------------------------------------ */ + +/* Protocol messages (intro bundles, ratchet envelopes) are well under 2 KB; + * using modest constants keeps both queues inside a few hundred KB of stack. */ +#define MAX_ENVELOPES 32 +#define MAX_ENVELOPE_SZ 2048 + +typedef struct { + uint8_t data[MAX_ENVELOPE_SZ]; + size_t len; +} Envelope; + +typedef struct { + Envelope items[MAX_ENVELOPES]; + int head; + int tail; + int count; +} Queue; + +static void queue_init(Queue *q) +{ + memset(q, 0, sizeof(*q)); +} + +static void queue_push(Queue *q, const uint8_t *data, size_t len) +{ + assert(q->count < MAX_ENVELOPES && "delivery queue overflow"); + assert(len <= MAX_ENVELOPE_SZ && "envelope too large"); + memcpy(q->items[q->tail].data, data, len); + q->items[q->tail].len = len; + q->tail = (q->tail + 1) % MAX_ENVELOPES; + q->count++; +} + +static int queue_pop(Queue *q, const uint8_t **data_out, size_t *len_out) +{ + if (q->count == 0) return 0; + *data_out = q->items[q->head].data; + *len_out = q->items[q->head].len; + q->head = (q->head + 1) % MAX_ENVELOPES; + q->count--; + return 1; +} + +/* ------------------------------------------------------------------ + * Delivery callback: userdata is the sending client's Queue*. + * ------------------------------------------------------------------ */ + +static int32_t deliver_cb( + const uint8_t *addr_ptr, size_t addr_len, + const uint8_t *data_ptr, size_t data_len, + void *userdata) +{ + (void)addr_ptr; (void)addr_len; /* routing is done manually below */ + queue_push((Queue *)userdata, data_ptr, data_len); + return 0; +} + +/* ------------------------------------------------------------------ + * Helper: pop one envelope from `outbox` and push it into `receiver`. + * ------------------------------------------------------------------ */ + +static void route( + const ClientOps *ops, + Queue *outbox, + ClientHandle_t *receiver, + PushInboundResult_t *out) +{ + const uint8_t *data; + size_t len; + int ok = queue_pop(outbox, &data, &len); + assert(ok && "expected an envelope in the outbox"); + ops->push_inbound(receiver, data, len, out); + assert(out->error_code == CLIENT_FFI_OK && "push_inbound failed"); +} + +/* ------------------------------------------------------------------ + * Entry point called from the Rust test harness. + * Returns 0 on success, non-zero on failure. + * ------------------------------------------------------------------ */ + +int run_alice_bob_test(const ClientOps *ops) +{ + Queue alice_outbox, bob_outbox; + queue_init(&alice_outbox); + queue_init(&bob_outbox); + + /* ---- Create clients ------------------------------------------------- */ + /* Each client's delivery callback pushes to its own outbox. */ + ClientHandle_t *alice = ops->create( + (const uint8_t *)"alice", 5, deliver_cb, &alice_outbox); + ClientHandle_t *bob = ops->create( + (const uint8_t *)"bob", 3, deliver_cb, &bob_outbox); + + assert(alice && "create returned NULL for alice"); + assert(bob && "create returned NULL for bob"); + + /* ---- Bob generates an intro bundle ---------------------------------- */ + CreateIntroResult_t bob_intro; + memset(&bob_intro, 0, sizeof(bob_intro)); + ops->create_intro_bundle(bob, &bob_intro); + assert(bob_intro.error_code == CLIENT_FFI_OK); + + /* ---- Alice initiates a conversation with Bob ------------------------ */ + CreateConvoResult_t alice_convo; + memset(&alice_convo, 0, sizeof(alice_convo)); + ops->create_conversation( + alice, + bob_intro.intro_bytes_ptr, bob_intro.intro_bytes_len, + (const uint8_t *)"hello bob", 9, + &alice_convo); + assert(alice_convo.error_code == CLIENT_FFI_OK); + ops->destroy_intro_result(&bob_intro); + + /* ---- Route alice -> bob --------------------------------------------- */ + PushInboundResult_t recv; + memset(&recv, 0, sizeof(recv)); + route(ops, &alice_outbox, bob, &recv); + + assert(recv.has_content && "expected content from alice"); + assert(recv.is_new_convo && "expected new-conversation flag"); + assert(recv.content_len == 9); + assert(memcmp(recv.content_ptr, "hello bob", 9) == 0); + + /* Save Bob's convo_id for replies */ + uint8_t bob_cid[256]; + size_t bob_cid_len = recv.convo_id_len; + assert(bob_cid_len < sizeof(bob_cid)); + memcpy(bob_cid, recv.convo_id_ptr, bob_cid_len); + ops->destroy_inbound_result(&recv); + + /* ---- Bob replies ---------------------------------------------------- */ + int32_t rc = ops->send_message( + bob, bob_cid, bob_cid_len, + (const uint8_t *)"hi alice", 8); + assert(rc == CLIENT_FFI_OK); + + /* Route bob -> alice */ + memset(&recv, 0, sizeof(recv)); + route(ops, &bob_outbox, alice, &recv); + assert(recv.has_content && "expected content from bob"); + assert(!recv.is_new_convo && "unexpected new-convo flag"); + assert(recv.content_len == 8); + assert(memcmp(recv.content_ptr, "hi alice", 8) == 0); + ops->destroy_inbound_result(&recv); + + /* ---- Multiple back-and-forth rounds --------------------------------- */ + for (int i = 0; i < 3; i++) { + char msg[32]; + int mlen = snprintf(msg, sizeof(msg), "msg %d", i); + + rc = ops->send_message( + alice, + alice_convo.convo_id_ptr, alice_convo.convo_id_len, + (const uint8_t *)msg, (size_t)mlen); + assert(rc == CLIENT_FFI_OK); + + memset(&recv, 0, sizeof(recv)); + route(ops, &alice_outbox, bob, &recv); + assert(recv.has_content); + assert((int)recv.content_len == mlen); + assert(memcmp(recv.content_ptr, msg, (size_t)mlen) == 0); + + char reply[32]; + int rlen = snprintf(reply, sizeof(reply), "reply %d", i); + + rc = ops->send_message( + bob, bob_cid, bob_cid_len, + (const uint8_t *)reply, (size_t)rlen); + assert(rc == CLIENT_FFI_OK); + ops->destroy_inbound_result(&recv); + + memset(&recv, 0, sizeof(recv)); + route(ops, &bob_outbox, alice, &recv); + assert(recv.has_content); + assert((int)recv.content_len == rlen); + assert(memcmp(recv.content_ptr, reply, (size_t)rlen) == 0); + ops->destroy_inbound_result(&recv); + } + + /* ---- Cleanup -------------------------------------------------------- */ + ops->destroy_convo_result(&alice_convo); + ops->destroy(alice); + ops->destroy(bob); + return 0; +} diff --git a/crates/c-test/c/delivery.c b/crates/c-test/c/delivery.c new file mode 100644 index 0000000..1cf58e4 --- /dev/null +++ b/crates/c-test/c/delivery.c @@ -0,0 +1,88 @@ +/* + * C delivery implementation for the c-test platform. + * + * Provides a simple fixed-size queue and a delivery callback. The queue acts + * as the outbox for a client: every outbound envelope is pushed here; the test + * (orchestrated from Rust) pops envelopes and routes them to the receiving + * client via client_push_inbound. + * + * This file has NO dependencies on the client-ffi or libchat Rust crates. + */ + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ + * Queue implementation + * ------------------------------------------------------------------ */ + +#define MAX_ENVELOPES 64 +#define MAX_ENVELOPE_SZ 32768 + +typedef struct { + uint8_t data[MAX_ENVELOPE_SZ]; + size_t len; +} Envelope; + +typedef struct Queue { + Envelope items[MAX_ENVELOPES]; + int head; + int tail; + int count; +} Queue; + +Queue *c_queue_new(void) +{ + Queue *q = (Queue *)calloc(1, sizeof(Queue)); + assert(q && "out of memory"); + return q; +} + +void c_queue_free(Queue *q) +{ + free(q); +} + +/** + * Pop the oldest envelope from the queue. + * + * @param q Queue to pop from. + * @param data_out Set to a pointer into the queue's internal buffer. + * Valid only until the next push or free. + * @param len_out Set to the envelope length in bytes. + * @return 1 if an envelope was available, 0 if the queue was empty. + */ +int c_queue_pop(Queue *q, const uint8_t **data_out, size_t *len_out) +{ + if (q->count == 0) return 0; + *data_out = q->items[q->head].data; + *len_out = q->items[q->head].len; + q->head = (q->head + 1) % MAX_ENVELOPES; + q->count--; + return 1; +} + +/* ------------------------------------------------------------------ + * Delivery callback + * + * `userdata` must be a Queue* for the sending client's outbox. + * ------------------------------------------------------------------ */ + +int32_t c_deliver_cb( + const uint8_t *addr_ptr, size_t addr_len, + const uint8_t *data_ptr, size_t data_len, + void *userdata) +{ + (void)addr_ptr; (void)addr_len; /* routing is done externally */ + Queue *q = (Queue *)userdata; + assert(q->count < MAX_ENVELOPES && "delivery queue overflow"); + assert(data_len <= MAX_ENVELOPE_SZ && "envelope too large for queue"); + memcpy(q->items[q->tail].data, data_ptr, data_len); + q->items[q->tail].len = data_len; + q->tail = (q->tail + 1) % MAX_ENVELOPES; + q->count++; + return 0; +} diff --git a/crates/c-test/src/lib.rs b/crates/c-test/src/lib.rs new file mode 100644 index 0000000..db7a9b2 --- /dev/null +++ b/crates/c-test/src/lib.rs @@ -0,0 +1,86 @@ +/// c-test platform — delivery implemented in C, protocol via client-ffi. +/// +/// The C test code (`c/alice_bob_test.c`) receives a `ClientOps` vtable +/// populated here with all the client-ffi function pointers. This keeps +/// the C archive free of direct Rust-symbol references, which avoids the +/// linker-ordering problem (C archives are searched before Rust rlibs). +/// The Rust side explicitly names every function in the vtable, so Rust's +/// DCE cannot eliminate them from the binary. + +#[cfg(test)] +mod tests { + use std::ffi::c_void; + + use client_ffi::{ + ClientHandle, CreateConvoResult, CreateIntroResult, DeliverFn, PushInboundResult, + client_create, client_create_conversation, client_create_intro_bundle, client_destroy, + client_push_inbound, client_send_message, destroy_create_convo_result, + destroy_create_intro_result, destroy_push_inbound_result, + }; + + // ------------------------------------------------------------------------- + // ClientOps vtable — must match the C definition in client_ffi.h exactly. + // ------------------------------------------------------------------------- + + #[repr(C)] + struct ClientOps { + create: unsafe extern "C" fn( + *const u8, usize, + Option, + *mut c_void, + ) -> *mut ClientHandle, + destroy: unsafe extern "C" fn(*mut ClientHandle), + create_intro_bundle: + unsafe extern "C" fn(*mut ClientHandle, *mut CreateIntroResult), + destroy_intro_result: unsafe extern "C" fn(*mut CreateIntroResult), + create_conversation: unsafe extern "C" fn( + *mut ClientHandle, + *const u8, usize, + *const u8, usize, + *mut CreateConvoResult, + ), + destroy_convo_result: unsafe extern "C" fn(*mut CreateConvoResult), + send_message: unsafe extern "C" fn( + *mut ClientHandle, + *const u8, usize, + *const u8, usize, + ) -> i32, + push_inbound: unsafe extern "C" fn( + *mut ClientHandle, + *const u8, usize, + *mut PushInboundResult, + ), + destroy_inbound_result: unsafe extern "C" fn(*mut PushInboundResult), + } + + unsafe extern "C" { + fn run_alice_bob_test(ops: *const ClientOps) -> i32; + } + + // ------------------------------------------------------------------------- + // Test + // ------------------------------------------------------------------------- + + /// End-to-end Alice-Bob exchange. + /// + /// Delivery is fully in C (`c/alice_bob_test.c`): queue management, + /// callback, and test orchestration. The vtable below pins every + /// client-ffi symbol into the binary so neither the Rust compiler + /// nor the linker can drop them. + #[test] + fn alice_bob_c_delivery() { + let ops = ClientOps { + create: client_create, + destroy: client_destroy, + create_intro_bundle: client_create_intro_bundle, + destroy_intro_result: destroy_create_intro_result, + create_conversation: client_create_conversation, + destroy_convo_result: destroy_create_convo_result, + send_message: client_send_message, + push_inbound: client_push_inbound, + destroy_inbound_result: destroy_push_inbound_result, + }; + let result = unsafe { run_alice_bob_test(&ops) }; + assert_eq!(result, 0, "C alice-bob test returned non-zero"); + } +} diff --git a/crates/client-ffi/Cargo.toml b/crates/client-ffi/Cargo.toml new file mode 100644 index 0000000..86aede8 --- /dev/null +++ b/crates/client-ffi/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "client-ffi" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +client = { path = "../client" } +libchat = { workspace = true } diff --git a/crates/client-ffi/client_ffi.h b/crates/client-ffi/client_ffi.h new file mode 100644 index 0000000..e9a1060 --- /dev/null +++ b/crates/client-ffi/client_ffi.h @@ -0,0 +1,207 @@ +#ifndef CLIENT_FFI_H +#define CLIENT_FFI_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ + * Opaque handle returned by client_create. + * ------------------------------------------------------------------ */ + +typedef struct ClientHandle ClientHandle_t; + +/* ------------------------------------------------------------------ + * C delivery callback. + * + * Called for each outbound envelope. `addr_ptr/addr_len` is the + * delivery address (UTF-8, not NUL-terminated). `data_ptr/data_len` + * is the encrypted payload. `userdata` is the pointer supplied to + * client_create. Return 0 on success, negative on error. + * ------------------------------------------------------------------ */ + +typedef int32_t (*DeliverFn_t)( + const uint8_t *addr_ptr, size_t addr_len, + const uint8_t *data_ptr, size_t data_len, + void *userdata); + +/* ------------------------------------------------------------------ + * Error codes (match crates/client-ffi/src/api.rs ErrorCode) + * ------------------------------------------------------------------ */ + +#define CLIENT_FFI_OK 0 +#define CLIENT_FFI_ERR_BAD_UTF8 (-1) +#define CLIENT_FFI_ERR_BAD_INTRO (-2) +#define CLIENT_FFI_ERR_DELIVERY (-3) +#define CLIENT_FFI_ERR_UNKNOWN (-4) + +/* ------------------------------------------------------------------ + * Result structs — all heap-allocated fields are owned by the caller + * and must be freed via the matching destroy_* function. + * ------------------------------------------------------------------ */ + +typedef struct { + int32_t error_code; + uint8_t *intro_bytes_ptr; + size_t intro_bytes_len; +} CreateIntroResult_t; + +typedef struct { + int32_t error_code; + /* UTF-8, NOT NUL-terminated */ + uint8_t *convo_id_ptr; + size_t convo_id_len; +} CreateConvoResult_t; + +typedef struct { + int32_t error_code; + bool has_content; + bool is_new_convo; + /* UTF-8, NOT NUL-terminated. NULL when has_content is false. */ + uint8_t *convo_id_ptr; + size_t convo_id_len; + /* NULL when has_content is false. */ + uint8_t *content_ptr; + size_t content_len; +} PushInboundResult_t; + +/* ------------------------------------------------------------------ + * Lifecycle + * ------------------------------------------------------------------ */ + +/** + * Create an ephemeral in-memory client. + * + * @param name_ptr UTF-8 installation name (need not be NUL-terminated). + * @param name_len Byte length of name_ptr. + * @param callback Non-null delivery callback. + * @param userdata Opaque pointer forwarded to each callback invocation. + * @return Opaque handle, or NULL on error. Free with client_destroy. + */ +ClientHandle_t *client_create( + const uint8_t *name_ptr, size_t name_len, + DeliverFn_t callback, + void *userdata); + +/** + * Destroy a client handle. Must not be used after this call. + */ +void client_destroy(ClientHandle_t *handle); + +/* ------------------------------------------------------------------ + * Identity + * ------------------------------------------------------------------ */ + +/** + * Return a pointer to the installation name bytes (UTF-8, not NUL-terminated). + * The pointer is valid until the next mutating call or client_destroy. + * The caller must NOT free it. + * + * @param out_len Receives the byte length of the returned string. + */ +const uint8_t *client_installation_name(const ClientHandle_t *handle, size_t *out_len); + +/* ------------------------------------------------------------------ + * Intro bundle + * ------------------------------------------------------------------ */ + +/** + * Generate a serialised introduction bundle for out-of-band sharing. + * Always call destroy_create_intro_result when done. + */ +void client_create_intro_bundle(ClientHandle_t *handle, CreateIntroResult_t *out); + +/** Free the result from client_create_intro_bundle. */ +void destroy_create_intro_result(CreateIntroResult_t *result); + +/* ------------------------------------------------------------------ + * Create conversation + * ------------------------------------------------------------------ */ + +/** + * Parse an intro bundle and initiate a private conversation. + * Outbound envelopes are dispatched through the delivery callback. + * Always call destroy_create_convo_result when done. + */ +void client_create_conversation( + ClientHandle_t *handle, + const uint8_t *bundle_ptr, size_t bundle_len, + const uint8_t *content_ptr, size_t content_len, + CreateConvoResult_t *out); + +/** Free the result from client_create_conversation. */ +void destroy_create_convo_result(CreateConvoResult_t *result); + +/* ------------------------------------------------------------------ + * Send message + * ------------------------------------------------------------------ */ + +/** + * Encrypt content and dispatch outbound envelopes. + * + * @param convo_id_ptr UTF-8 conversation ID (not NUL-terminated). + * @return CLIENT_FFI_OK on success, error code otherwise. + */ +int32_t client_send_message( + ClientHandle_t *handle, + const uint8_t *convo_id_ptr, size_t convo_id_len, + const uint8_t *content_ptr, size_t content_len); + +/* ------------------------------------------------------------------ + * Push inbound + * ------------------------------------------------------------------ */ + +/** + * Decrypt an inbound payload. has_content is false for protocol frames. + * Always call destroy_push_inbound_result when error_code == CLIENT_FFI_OK. + */ +void client_push_inbound( + ClientHandle_t *handle, + const uint8_t *payload_ptr, size_t payload_len, + PushInboundResult_t *out); + +/** Free the result from client_push_inbound. */ +void destroy_push_inbound_result(PushInboundResult_t *result); + +/* ------------------------------------------------------------------ + * ClientOps — vtable used by c-test so C can call the Rust client-ffi + * functions indirectly via function pointers supplied by the Rust harness. + * + * This avoids the linker-ordering problem that arises when C archives + * are searched before Rust rlibs: the C code has no direct undefined + * references to Rust symbols; it only uses the pointers it receives. + * ------------------------------------------------------------------ */ + +typedef struct { + ClientHandle_t *(*create)( + const uint8_t *name_ptr, size_t name_len, + DeliverFn_t callback, void *userdata); + void (*destroy)(ClientHandle_t *handle); + void (*create_intro_bundle)(ClientHandle_t *handle, CreateIntroResult_t *out); + void (*destroy_intro_result)(CreateIntroResult_t *result); + void (*create_conversation)( + ClientHandle_t *handle, + const uint8_t *bundle_ptr, size_t bundle_len, + const uint8_t *content_ptr, size_t content_len, + CreateConvoResult_t *out); + void (*destroy_convo_result)(CreateConvoResult_t *result); + int32_t (*send_message)( + ClientHandle_t *handle, + const uint8_t *convo_id_ptr, size_t convo_id_len, + const uint8_t *content_ptr, size_t content_len); + void (*push_inbound)( + ClientHandle_t *handle, + const uint8_t *payload_ptr, size_t payload_len, + PushInboundResult_t *out); + void (*destroy_inbound_result)(PushInboundResult_t *result); +} ClientOps; + +#ifdef __cplusplus +} +#endif + +#endif /* CLIENT_FFI_H */ diff --git a/crates/client-ffi/src/api.rs b/crates/client-ffi/src/api.rs new file mode 100644 index 0000000..a39b2e0 --- /dev/null +++ b/crates/client-ffi/src/api.rs @@ -0,0 +1,399 @@ +use std::sync::Arc; + +use client::{ChatClient, ClientError}; + +use crate::delivery::{CDelivery, DeliverFn}; + +// --------------------------------------------------------------------------- +// Opaque client handle +// --------------------------------------------------------------------------- + +pub struct ClientHandle(pub(crate) ChatClient); + +// --------------------------------------------------------------------------- +// Memory helpers: leak a Vec / String as raw pointer + length, and the +// corresponding safe reconstruction for frees. Box<[u8]> is used so that +// capacity == length, which simplifies round-tripping via from_raw. +// --------------------------------------------------------------------------- + +fn leak_bytes(v: Vec) -> (*mut u8, usize) { + let mut b = v.into_boxed_slice(); + let len = b.len(); + let ptr = b.as_mut_ptr(); + std::mem::forget(b); + (ptr, len) +} + +/// # Safety +/// `ptr` must have been returned by `leak_bytes` or `leak_string` with the same `len`. +unsafe fn free_bytes(ptr: *mut u8, len: usize) { + if !ptr.is_null() && len > 0 { + unsafe { + drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); + } + } +} + +fn leak_string(s: String) -> (*mut u8, usize) { + leak_bytes(s.into_bytes()) +} + +// --------------------------------------------------------------------------- +// Error codes +// --------------------------------------------------------------------------- + +#[repr(i32)] +pub enum ErrorCode { + None = 0, + BadUtf8 = -1, + BadIntro = -2, + DeliveryFail = -3, + UnknownError = -4, +} + +// --------------------------------------------------------------------------- +// Result structs +// --------------------------------------------------------------------------- + +/// Output of client_create_intro_bundle. +/// Free with destroy_create_intro_result. +#[repr(C)] +pub struct CreateIntroResult { + pub error_code: i32, + /// Heap-allocated; free via destroy_create_intro_result. + pub intro_bytes_ptr: *mut u8, + pub intro_bytes_len: usize, +} + +/// Output of client_create_conversation. +/// Free with destroy_create_convo_result. +#[repr(C)] +pub struct CreateConvoResult { + pub error_code: i32, + /// UTF-8, not NUL-terminated. Free via destroy_create_convo_result. + pub convo_id_ptr: *mut u8, + pub convo_id_len: usize, +} + +/// Output of client_push_inbound. +/// Free with destroy_push_inbound_result when error_code == 0. +#[repr(C)] +pub struct PushInboundResult { + pub error_code: i32, + /// False when the payload was a protocol frame with no user content. + pub has_content: bool, + pub is_new_convo: bool, + /// UTF-8, not NUL-terminated. Null when has_content is false. + pub convo_id_ptr: *mut u8, + pub convo_id_len: usize, + /// Null when has_content is false. + pub content_ptr: *mut u8, + pub content_len: usize, +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// Create an ephemeral in-memory client. Identity is lost on drop. +/// +/// # Safety +/// - `name_ptr` must point to `name_len` valid UTF-8 bytes. +/// - `callback` must be non-null. +/// - `userdata` is threaded to every callback invocation; caller ensures its validity. +/// - The returned pointer must be freed with `client_destroy`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_create( + name_ptr: *const u8, + name_len: usize, + callback: Option, + userdata: *mut core::ffi::c_void, +) -> *mut ClientHandle { + let name = unsafe { + match std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + } + }; + let Some(callback) = callback else { return std::ptr::null_mut() }; + let delivery = CDelivery { callback, userdata }; + Box::into_raw(Box::new(ClientHandle(ChatClient::new(name, delivery)))) +} + +/// Free a client handle returned by `client_create`. +/// +/// # Safety +/// - `handle` must be a valid non-null pointer from `client_create`. +/// - `handle` must not be used after this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_destroy(handle: *mut ClientHandle) { + if !handle.is_null() { + unsafe { drop(Box::from_raw(handle)) }; + } +} + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Return the installation name as a pointer to UTF-8 bytes (not NUL-terminated). +/// The `*out_len` is set to the length. The pointer is valid until the handle is +/// destroyed; the caller must NOT free it. +/// +/// # Safety +/// - `handle` must be a valid non-null pointer from `client_create`. +/// - `out_len` must be a valid non-null pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_installation_name( + handle: *const ClientHandle, + out_len: *mut usize, +) -> *const u8 { + if handle.is_null() || out_len.is_null() { + return std::ptr::null(); + } + let name = unsafe { (*handle).0.installation_name() }; + unsafe { *out_len = name.len() }; + name.as_ptr() +} + +// --------------------------------------------------------------------------- +// Intro bundle +// --------------------------------------------------------------------------- + +/// Produce a serialised introduction bundle for out-of-band sharing. +/// +/// # Safety +/// - `handle` and `out` must be valid non-null pointers. +/// - Call `destroy_create_intro_result` when done. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_create_intro_bundle( + handle: *mut ClientHandle, + out: *mut CreateIntroResult, +) { + if handle.is_null() || out.is_null() { + if !out.is_null() { + unsafe { + *out = CreateIntroResult { + error_code: ErrorCode::UnknownError as i32, + intro_bytes_ptr: std::ptr::null_mut(), + intro_bytes_len: 0, + }; + } + } + return; + } + unsafe { + *out = match (*handle).0.create_intro_bundle() { + Ok(bytes) => { + let (ptr, len) = leak_bytes(bytes); + CreateIntroResult { error_code: ErrorCode::None as i32, intro_bytes_ptr: ptr, intro_bytes_len: len } + } + Err(_) => CreateIntroResult { + error_code: ErrorCode::UnknownError as i32, + intro_bytes_ptr: std::ptr::null_mut(), + intro_bytes_len: 0, + }, + }; + } +} + +/// Free the result from `client_create_intro_bundle`. +/// +/// # Safety +/// `result` must point to a `CreateIntroResult` previously populated by +/// `client_create_intro_bundle`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_create_intro_result(result: *mut CreateIntroResult) { + if result.is_null() { return; } + unsafe { + let r = &mut *result; + free_bytes(r.intro_bytes_ptr, r.intro_bytes_len); + r.intro_bytes_ptr = std::ptr::null_mut(); + r.intro_bytes_len = 0; + } +} + +// --------------------------------------------------------------------------- +// Create conversation +// --------------------------------------------------------------------------- + +/// Parse an intro bundle and initiate a private conversation. +/// Outbound envelopes are dispatched through the delivery callback. +/// +/// # Safety +/// - All pointer parameters must be valid for the stated lengths. +/// - Call `destroy_create_convo_result` when done. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_create_conversation( + handle: *mut ClientHandle, + bundle_ptr: *const u8, + bundle_len: usize, + content_ptr: *const u8, + content_len: usize, + out: *mut CreateConvoResult, +) { + if handle.is_null() || out.is_null() { + if !out.is_null() { + unsafe { + *out = CreateConvoResult { + error_code: ErrorCode::UnknownError as i32, + convo_id_ptr: std::ptr::null_mut(), + convo_id_len: 0, + }; + } + } + return; + } + unsafe { + let bundle = std::slice::from_raw_parts(bundle_ptr, bundle_len); + let content = std::slice::from_raw_parts(content_ptr, content_len); + *out = match (*handle).0.create_conversation(bundle, content) { + Ok(convo_id) => { + let (ptr, len) = leak_string(convo_id.to_string()); + CreateConvoResult { error_code: ErrorCode::None as i32, convo_id_ptr: ptr, convo_id_len: len } + } + Err(ClientError::Chat(_)) => CreateConvoResult { + error_code: ErrorCode::BadIntro as i32, + convo_id_ptr: std::ptr::null_mut(), + convo_id_len: 0, + }, + Err(ClientError::Delivery(_)) => CreateConvoResult { + error_code: ErrorCode::DeliveryFail as i32, + convo_id_ptr: std::ptr::null_mut(), + convo_id_len: 0, + }, + }; + } +} + +/// Free the result from `client_create_conversation`. +/// +/// # Safety +/// `result` must point to a `CreateConvoResult` previously populated by +/// `client_create_conversation`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_create_convo_result(result: *mut CreateConvoResult) { + if result.is_null() { return; } + unsafe { + let r = &mut *result; + free_bytes(r.convo_id_ptr, r.convo_id_len); + r.convo_id_ptr = std::ptr::null_mut(); + r.convo_id_len = 0; + } +} + +// --------------------------------------------------------------------------- +// Send message +// --------------------------------------------------------------------------- + +/// Encrypt `content` and dispatch outbound envelopes. Returns an `ErrorCode`. +/// +/// # Safety +/// - All pointer parameters must be valid for the stated lengths. +/// - `convo_id` bytes must be valid UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_send_message( + handle: *mut ClientHandle, + convo_id_ptr: *const u8, + convo_id_len: usize, + content_ptr: *const u8, + content_len: usize, +) -> i32 { + if handle.is_null() { return ErrorCode::UnknownError as i32; } + unsafe { + let id_str = match std::str::from_utf8(std::slice::from_raw_parts(convo_id_ptr, convo_id_len)) { + Ok(s) => s, + Err(_) => return ErrorCode::BadUtf8 as i32, + }; + let convo_id: client::ConversationIdOwned = Arc::from(id_str); + let content = std::slice::from_raw_parts(content_ptr, content_len); + match (*handle).0.send_message(&convo_id, content) { + Ok(()) => ErrorCode::None as i32, + Err(ClientError::Delivery(_)) => ErrorCode::DeliveryFail as i32, + Err(_) => ErrorCode::UnknownError as i32, + } + } +} + +// --------------------------------------------------------------------------- +// Push inbound +// --------------------------------------------------------------------------- + +/// Decrypt an inbound payload. `has_content` is false for protocol frames. +/// +/// # Safety +/// - All pointer parameters must be valid for the stated lengths. +/// - Call `destroy_push_inbound_result` when error_code == 0. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn client_push_inbound( + handle: *mut ClientHandle, + payload_ptr: *const u8, + payload_len: usize, + out: *mut PushInboundResult, +) { + if handle.is_null() || out.is_null() { + if !out.is_null() { + unsafe { *out = push_inbound_error(ErrorCode::UnknownError as i32) }; + } + return; + } + unsafe { + let payload = std::slice::from_raw_parts(payload_ptr, payload_len); + *out = match (*handle).0.receive(payload) { + Ok(Some(cd)) => { + let (cid_ptr, cid_len) = leak_string(cd.conversation_id); + let (con_ptr, con_len) = leak_bytes(cd.data); + PushInboundResult { + error_code: ErrorCode::None as i32, + has_content: true, + is_new_convo: cd.is_new_convo, + convo_id_ptr: cid_ptr, + convo_id_len: cid_len, + content_ptr: con_ptr, + content_len: con_len, + } + } + Ok(None) => PushInboundResult { + error_code: ErrorCode::None as i32, + has_content: false, + is_new_convo: false, + convo_id_ptr: std::ptr::null_mut(), + convo_id_len: 0, + content_ptr: std::ptr::null_mut(), + content_len: 0, + }, + Err(_) => push_inbound_error(ErrorCode::UnknownError as i32), + }; + } +} + +fn push_inbound_error(error_code: i32) -> PushInboundResult { + PushInboundResult { + error_code, + has_content: false, + is_new_convo: false, + convo_id_ptr: std::ptr::null_mut(), + convo_id_len: 0, + content_ptr: std::ptr::null_mut(), + content_len: 0, + } +} + +/// Free the result from `client_push_inbound`. +/// +/// # Safety +/// `result` must point to a `PushInboundResult` previously populated by +/// `client_push_inbound`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_push_inbound_result(result: *mut PushInboundResult) { + if result.is_null() { return; } + unsafe { + let r = &mut *result; + free_bytes(r.convo_id_ptr, r.convo_id_len); + free_bytes(r.content_ptr, r.content_len); + r.convo_id_ptr = std::ptr::null_mut(); + r.convo_id_len = 0; + r.content_ptr = std::ptr::null_mut(); + r.content_len = 0; + } +} diff --git a/crates/client-ffi/src/delivery.rs b/crates/client-ffi/src/delivery.rs new file mode 100644 index 0000000..448fe5c --- /dev/null +++ b/crates/client-ffi/src/delivery.rs @@ -0,0 +1,40 @@ +use libchat::AddressedEnvelope; + +use client::DeliveryService; + +/// C callback invoked for each outbound envelope. Return 0 on success, negative on error. +/// `userdata` is an opaque pointer threaded through without interpretation. +pub type DeliverFn = unsafe extern "C" fn( + addr_ptr: *const u8, + addr_len: usize, + data_ptr: *const u8, + data_len: usize, + userdata: *mut core::ffi::c_void, +) -> i32; + +pub struct CDelivery { + pub callback: DeliverFn, + pub userdata: *mut core::ffi::c_void, +} + +// SAFETY: The C caller is responsible for ensuring userdata outlives the client +// and that any concurrent access is externally synchronized. +unsafe impl Send for CDelivery {} + +impl DeliveryService for CDelivery { + type Error = i32; + + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), i32> { + let addr = envelope.delivery_address.as_bytes(); + let rc = unsafe { + (self.callback)( + addr.as_ptr(), + addr.len(), + envelope.data.as_ptr(), + envelope.data.len(), + self.userdata, + ) + }; + if rc == 0 { Ok(()) } else { Err(rc) } + } +} diff --git a/crates/client-ffi/src/lib.rs b/crates/client-ffi/src/lib.rs new file mode 100644 index 0000000..05be818 --- /dev/null +++ b/crates/client-ffi/src/lib.rs @@ -0,0 +1,5 @@ +mod api; +mod delivery; + +pub use api::*; +pub use delivery::*; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index c3ef4ca..3ef82ea 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -78,11 +78,6 @@ impl ChatClient { self.ctx.handle_payload(payload).map_err(Into::into) } - /// Access the delivery service (e.g. to pop inbound envelopes in tests). - pub fn delivery_mut(&mut self) -> &mut D { - &mut self.delivery - } - fn dispatch_all( &mut self, envelopes: Vec, diff --git a/crates/client/src/platforms/test/delivery.rs b/crates/client/src/platforms/test/delivery.rs index 3215811..eb43b16 100644 --- a/crates/client/src/platforms/test/delivery.rs +++ b/crates/client/src/platforms/test/delivery.rs @@ -1,21 +1,41 @@ use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; use libchat::AddressedEnvelope; use crate::delivery::DeliveryService; -/// Mock delivery for TestPlatform. Envelopes are pushed to `inbox`; tests pop -/// them manually and feed bytes into the peer's `receive()`. +/// Companion handle tests retain to observe outbound envelopes without accessing +/// the client internals. +#[derive(Clone, Default)] +pub struct MockOutbox(pub Arc>>>); + +impl MockOutbox { + pub fn pop_front(&self) -> Option> { + self.0.lock().unwrap().pop_front() + } +} + +/// Mock delivery for TestPlatform. Envelopes are pushed to the shared outbox; +/// tests pop them via a `MockOutbox` handle and feed bytes into the peer's +/// `receive()`. #[derive(Default)] pub struct MockDelivery { - pub inbox: VecDeque>, + outbox: Arc>>>, +} + +impl MockDelivery { + /// Create a delivery wired to an existing outbox handle. + pub fn with_outbox(outbox: &MockOutbox) -> Self { + Self { outbox: Arc::clone(&outbox.0) } + } } impl DeliveryService for MockDelivery { type Error = std::convert::Infallible; fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error> { - self.inbox.push_back(envelope.data); + self.outbox.lock().unwrap().push_back(envelope.data); Ok(()) } } diff --git a/crates/client/src/platforms/test/mod.rs b/crates/client/src/platforms/test/mod.rs index a1131b0..41f199e 100644 --- a/crates/client/src/platforms/test/mod.rs +++ b/crates/client/src/platforms/test/mod.rs @@ -1,6 +1,6 @@ mod delivery; -pub use delivery::MockDelivery; +pub use delivery::{MockDelivery, MockOutbox}; use crate::{client::ChatClient, platform::Platform}; diff --git a/crates/client/tests/alice_and_bob.rs b/crates/client/tests/alice_and_bob.rs index 145dc79..373ee21 100644 --- a/crates/client/tests/alice_and_bob.rs +++ b/crates/client/tests/alice_and_bob.rs @@ -1,23 +1,25 @@ -use client::platforms::test::{MockDelivery, TestPlatform}; -use client::{ChatClient, ContentData, ConversationIdOwned, Platform, StorageConfig}; +use client::platforms::test::{MockDelivery, MockOutbox}; +use client::{ChatClient, ContentData, ConversationIdOwned, StorageConfig}; use std::sync::Arc; -fn new_client(name: &str) -> ChatClient { - TestPlatform.into_client(name).unwrap() +fn new_client(name: &str) -> (ChatClient, MockOutbox) { + let outbox = MockOutbox::default(); + let delivery = MockDelivery::with_outbox(&outbox); + (ChatClient::new(name, delivery), outbox) } fn pop_and_receive( - sender: &mut ChatClient, + sender_outbox: &MockOutbox, receiver: &mut ChatClient, ) -> Option { - let raw = sender.delivery_mut().inbox.pop_front().expect("expected envelope"); + let raw = sender_outbox.pop_front().expect("expected envelope"); receiver.receive(&raw).expect("receive failed") } #[test] fn alice_bob_message_exchange() { - let mut alice = new_client("alice"); - let mut bob = new_client("bob"); + let (mut alice, alice_outbox) = new_client("alice"); + let (mut bob, bob_outbox) = new_client("bob"); // Exchange intro bundles out-of-band let bob_bundle = bob.create_intro_bundle().unwrap(); @@ -28,7 +30,7 @@ fn alice_bob_message_exchange() { .unwrap(); // Bob receives Alice's initial message - let content = pop_and_receive(&mut alice, &mut bob).expect("expected content"); + let content = pop_and_receive(&alice_outbox, &mut bob).expect("expected content"); assert_eq!(content.data, b"hello bob"); assert!(content.is_new_convo); @@ -36,7 +38,7 @@ fn alice_bob_message_exchange() { // Bob replies bob.send_message(&bob_convo_id, b"hi alice").unwrap(); - let content = pop_and_receive(&mut bob, &mut alice).expect("expected content"); + let content = pop_and_receive(&bob_outbox, &mut alice).expect("expected content"); assert_eq!(content.data, b"hi alice"); assert!(!content.is_new_convo); @@ -44,12 +46,12 @@ fn alice_bob_message_exchange() { for i in 0u8..5 { let msg = format!("msg {i}"); alice.send_message(&alice_convo_id, msg.as_bytes()).unwrap(); - let content = pop_and_receive(&mut alice, &mut bob).expect("expected content"); + let content = pop_and_receive(&alice_outbox, &mut bob).expect("expected content"); assert_eq!(content.data, msg.as_bytes()); let reply = format!("reply {i}"); bob.send_message(&bob_convo_id, reply.as_bytes()).unwrap(); - let content = pop_and_receive(&mut bob, &mut alice).expect("expected content"); + let content = pop_and_receive(&bob_outbox, &mut alice).expect("expected content"); assert_eq!(content.data, reply.as_bytes()); }