This commit is contained in:
osmaczko 2026-03-26 22:38:38 +01:00
parent 5189bcf872
commit 504b743929
No known key found for this signature in database
GPG Key ID: 6A385380FD275B44
16 changed files with 1129 additions and 22 deletions

16
Cargo.lock generated
View File

@ -60,6 +60,14 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "c-test"
version = "0.1.0"
dependencies = [
"cc",
"client-ffi",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.54" version = "1.2.54"
@ -127,6 +135,14 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "client-ffi"
version = "0.1.0"
dependencies = [
"client",
"libchat",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"

View File

@ -8,6 +8,8 @@ members = [
"core/double-ratchets", "core/double-ratchets",
"core/storage", "core/storage",
"crates/client", "crates/client",
"crates/client-ffi",
"crates/c-test",
] ]
[workspace.dependencies] [workspace.dependencies]

13
crates/c-test/Cargo.toml Normal file
View File

@ -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"

17
crates/c-test/build.rs Normal file
View File

@ -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");
}

View File

@ -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 <assert.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ------------------------------------------------------------------
* 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;
}

View File

@ -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 <assert.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
/* ------------------------------------------------------------------
* 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;
}

86
crates/c-test/src/lib.rs Normal file
View File

@ -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<DeliverFn>,
*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");
}
}

View File

@ -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 }

View File

@ -0,0 +1,207 @@
#ifndef CLIENT_FFI_H
#define CLIENT_FFI_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 */

View File

@ -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<CDelivery>);
// ---------------------------------------------------------------------------
// Memory helpers: leak a Vec<u8> / 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<u8>) -> (*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<DeliverFn>,
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;
}
}

View File

@ -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) }
}
}

View File

@ -0,0 +1,5 @@
mod api;
mod delivery;
pub use api::*;
pub use delivery::*;

View File

@ -78,11 +78,6 @@ impl<D: DeliveryService> ChatClient<D> {
self.ctx.handle_payload(payload).map_err(Into::into) 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( fn dispatch_all(
&mut self, &mut self,
envelopes: Vec<AddressedEnvelope>, envelopes: Vec<AddressedEnvelope>,

View File

@ -1,21 +1,41 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use libchat::AddressedEnvelope; use libchat::AddressedEnvelope;
use crate::delivery::DeliveryService; use crate::delivery::DeliveryService;
/// Mock delivery for TestPlatform. Envelopes are pushed to `inbox`; tests pop /// Companion handle tests retain to observe outbound envelopes without accessing
/// them manually and feed bytes into the peer's `receive()`. /// the client internals.
#[derive(Clone, Default)]
pub struct MockOutbox(pub Arc<Mutex<VecDeque<Vec<u8>>>>);
impl MockOutbox {
pub fn pop_front(&self) -> Option<Vec<u8>> {
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)] #[derive(Default)]
pub struct MockDelivery { pub struct MockDelivery {
pub inbox: VecDeque<Vec<u8>>, outbox: Arc<Mutex<VecDeque<Vec<u8>>>>,
}
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 { impl DeliveryService for MockDelivery {
type Error = std::convert::Infallible; type Error = std::convert::Infallible;
fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error> { fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error> {
self.inbox.push_back(envelope.data); self.outbox.lock().unwrap().push_back(envelope.data);
Ok(()) Ok(())
} }
} }

View File

@ -1,6 +1,6 @@
mod delivery; mod delivery;
pub use delivery::MockDelivery; pub use delivery::{MockDelivery, MockOutbox};
use crate::{client::ChatClient, platform::Platform}; use crate::{client::ChatClient, platform::Platform};

View File

@ -1,23 +1,25 @@
use client::platforms::test::{MockDelivery, TestPlatform}; use client::platforms::test::{MockDelivery, MockOutbox};
use client::{ChatClient, ContentData, ConversationIdOwned, Platform, StorageConfig}; use client::{ChatClient, ContentData, ConversationIdOwned, StorageConfig};
use std::sync::Arc; use std::sync::Arc;
fn new_client(name: &str) -> ChatClient<MockDelivery> { fn new_client(name: &str) -> (ChatClient<MockDelivery>, MockOutbox) {
TestPlatform.into_client(name).unwrap() let outbox = MockOutbox::default();
let delivery = MockDelivery::with_outbox(&outbox);
(ChatClient::new(name, delivery), outbox)
} }
fn pop_and_receive( fn pop_and_receive(
sender: &mut ChatClient<MockDelivery>, sender_outbox: &MockOutbox,
receiver: &mut ChatClient<MockDelivery>, receiver: &mut ChatClient<MockDelivery>,
) -> Option<ContentData> { ) -> Option<ContentData> {
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") receiver.receive(&raw).expect("receive failed")
} }
#[test] #[test]
fn alice_bob_message_exchange() { fn alice_bob_message_exchange() {
let mut alice = new_client("alice"); let (mut alice, alice_outbox) = new_client("alice");
let mut bob = new_client("bob"); let (mut bob, bob_outbox) = new_client("bob");
// Exchange intro bundles out-of-band // Exchange intro bundles out-of-band
let bob_bundle = bob.create_intro_bundle().unwrap(); let bob_bundle = bob.create_intro_bundle().unwrap();
@ -28,7 +30,7 @@ fn alice_bob_message_exchange() {
.unwrap(); .unwrap();
// Bob receives Alice's initial message // 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_eq!(content.data, b"hello bob");
assert!(content.is_new_convo); assert!(content.is_new_convo);
@ -36,7 +38,7 @@ fn alice_bob_message_exchange() {
// Bob replies // Bob replies
bob.send_message(&bob_convo_id, b"hi alice").unwrap(); 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_eq!(content.data, b"hi alice");
assert!(!content.is_new_convo); assert!(!content.is_new_convo);
@ -44,12 +46,12 @@ fn alice_bob_message_exchange() {
for i in 0u8..5 { for i in 0u8..5 {
let msg = format!("msg {i}"); let msg = format!("msg {i}");
alice.send_message(&alice_convo_id, msg.as_bytes()).unwrap(); 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()); assert_eq!(content.data, msg.as_bytes());
let reply = format!("reply {i}"); let reply = format!("reply {i}");
bob.send_message(&bob_convo_id, reply.as_bytes()).unwrap(); 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()); assert_eq!(content.data, reply.as_bytes());
} }