Skip to main content

gbp_stack/
lib.rs

1//! C ABI surface for the Group Protocol Stack.
2//!
3//! Designed for consumption from `.NET` (or any FFI-capable runtime) via
4//! P/Invoke. The C ABI is grouped into the following families:
5//!
6//! * **GBP** (`gbp_node_*`) — the IP-like base layer: framing, AEAD, replay
7//!   window, control plane.
8//! * **GTP** (`gtp_client_*`) — text sub-protocol.
9//! * **GAP** (`gap_client_*`) — audio sub-protocol.
10//! * **GSP** (`gsp_client_*`) — signalling sub-protocol.
11//! * **MLS** (`gbp_mls_*`) — RFC 9420 context.
12//!
13//! Conventions:
14//!
15//! * **Handle-based** — every long-lived object lives in a Rust-side
16//!   registry keyed by an `i32` handle.
17//! * **GbpBuffer** — binary blobs are returned as `(ptr, len, cap)` triples
18//!   and MUST be released with `gbp_buffer_free`.
19//! * **Owned C-string** — text values are returned as owned `*mut c_char`
20//!   and MUST be released with `gbp_string_free`.
21//! * **Last error** — every fallible call writes to a thread-local error
22//!   slot that callers can read via `gbp_last_error`.
23
24#![allow(unsafe_op_in_unsafe_fn)]
25
26use gbp_stack::core::{ControlOpcode, NodeState, SignalType, StreamType};
27use gbp_stack::{
28    DeliveredPayload, ErrorObject, Event, GapAccept, GapClient, GbpFrame, GroupNode, GspAccept,
29    GspClient, GtpAccept, GtpClient, MlsContext, OutboundFrame, ProcessedKind, StreamLabel,
30};
31use openmls::prelude::tls_codec::Serialize as _;
32use openmls::prelude::*;
33use serde::Serialize;
34use std::cell::RefCell;
35use std::collections::HashMap;
36use std::ffi::{CString, c_char};
37use std::sync::{Arc, Mutex};
38use std::sync::atomic::{AtomicI32, Ordering};
39
40// ============================================================================
41// Buffer / string types (FFI memory protocol)
42// ============================================================================
43
44/// Binary buffer produced by Rust. The caller MUST release it via
45/// [`gbp_buffer_free`].
46#[repr(C)]
47pub struct GbpBuffer {
48    /// Pointer to the bytes (may be null when `len == 0`).
49    pub ptr: *mut u8,
50    /// Current length in bytes.
51    pub len: usize,
52    /// Capacity used when reconstructing the underlying `Vec` on free.
53    pub cap: usize,
54}
55
56impl GbpBuffer {
57    fn empty() -> Self {
58        Self { ptr: std::ptr::null_mut(), len: 0, cap: 0 }
59    }
60    fn from_vec(mut v: Vec<u8>) -> Self {
61        let ptr = v.as_mut_ptr();
62        let len = v.len();
63        let cap = v.capacity();
64        std::mem::forget(v);
65        Self { ptr, len, cap }
66    }
67}
68
69/// Releases a [`GbpBuffer`].
70///
71/// # Safety
72/// `buf` MUST have been returned by one of the `gbp_*` FFI functions and
73/// MUST NOT have been freed already.
74#[unsafe(no_mangle)]
75pub unsafe extern "C" fn gbp_buffer_free(buf: GbpBuffer) {
76    if buf.ptr.is_null() {
77        return;
78    }
79    unsafe {
80        let _ = Vec::from_raw_parts(buf.ptr, buf.len, buf.cap);
81    }
82}
83
84/// Releases a string previously returned by an FFI function.
85///
86/// # Safety
87/// `ptr` MUST have been returned by one of the `gbp_*` FFI functions.
88#[unsafe(no_mangle)]
89pub unsafe extern "C" fn gbp_string_free(ptr: *mut c_char) {
90    if ptr.is_null() {
91        return;
92    }
93    unsafe {
94        let _ = CString::from_raw(ptr);
95    }
96}
97
98fn alloc_cstring(s: &str) -> *mut c_char {
99    CString::new(s.as_bytes())
100        .unwrap_or_else(|_| CString::new(s.replace('\0', "?")).unwrap())
101        .into_raw()
102}
103
104// ============================================================================
105// Last-error machinery
106// ============================================================================
107
108thread_local! {
109    static LAST_ERROR: RefCell<String> = const { RefCell::new(String::new()) };
110}
111
112fn set_last_error(e: impl ToString) {
113    LAST_ERROR.with(|s| *s.borrow_mut() = e.to_string());
114}
115
116fn clear_last_error() {
117    LAST_ERROR.with(|s| s.borrow_mut().clear());
118}
119
120/// Returns the text of the last error, or an empty string if none.
121#[unsafe(no_mangle)]
122pub extern "C" fn gbp_last_error() -> *mut c_char {
123    LAST_ERROR.with(|s| alloc_cstring(&s.borrow()))
124}
125
126// ============================================================================
127// Handle registries
128// ============================================================================
129
130macro_rules! registry {
131    ($vis:vis $name:ident<$t:ty>) => {
132        $vis struct $name {
133            next: AtomicI32,
134            map: Mutex<HashMap<i32, Arc<Mutex<$t>>>>,
135        }
136        impl $name {
137            fn new() -> Self {
138                Self { next: AtomicI32::new(1), map: Mutex::new(HashMap::new()) }
139            }
140            fn insert(&self, v: $t) -> i32 {
141                let id = self.next.fetch_add(1, Ordering::Relaxed);
142                self.map.lock().unwrap().insert(id, Arc::new(Mutex::new(v)));
143                id
144            }
145            fn remove(&self, id: i32) {
146                self.map.lock().unwrap().remove(&id);
147            }
148            fn get(&self, id: i32) -> Option<Arc<Mutex<$t>>> {
149                self.map.lock().unwrap().get(&id).cloned()
150            }
151        }
152    };
153}
154
155registry!(MlsRegistry<MlsContext>);
156registry!(NodeRegistry<GroupNode>);
157registry!(GtpRegistry<GtpClient>);
158registry!(GapRegistry<GapClient>);
159registry!(GspRegistry<GspClient>);
160
161struct MlsBundles {
162    map: Mutex<HashMap<i32, KeyPackageBundle>>,
163}
164impl MlsBundles {
165    fn new() -> Self {
166        Self { map: Mutex::new(HashMap::new()) }
167    }
168}
169
170fn mls() -> &'static MlsRegistry {
171    use std::sync::OnceLock;
172    static R: OnceLock<MlsRegistry> = OnceLock::new();
173    R.get_or_init(MlsRegistry::new)
174}
175fn mls_bundles() -> &'static MlsBundles {
176    use std::sync::OnceLock;
177    static R: OnceLock<MlsBundles> = OnceLock::new();
178    R.get_or_init(MlsBundles::new)
179}
180fn nodes() -> &'static NodeRegistry {
181    use std::sync::OnceLock;
182    static R: OnceLock<NodeRegistry> = OnceLock::new();
183    R.get_or_init(NodeRegistry::new)
184}
185fn gtps() -> &'static GtpRegistry {
186    use std::sync::OnceLock;
187    static R: OnceLock<GtpRegistry> = OnceLock::new();
188    R.get_or_init(GtpRegistry::new)
189}
190fn gaps() -> &'static GapRegistry {
191    use std::sync::OnceLock;
192    static R: OnceLock<GapRegistry> = OnceLock::new();
193    R.get_or_init(GapRegistry::new)
194}
195fn gsps() -> &'static GspRegistry {
196    use std::sync::OnceLock;
197    static R: OnceLock<GspRegistry> = OnceLock::new();
198    R.get_or_init(GspRegistry::new)
199}
200
201// ============================================================================
202// Version
203// ============================================================================
204
205/// Returns the FFI library version with a short summary of the layers.
206#[unsafe(no_mangle)]
207pub extern "C" fn gbp_version() -> *mut c_char {
208    alloc_cstring(&format!(
209        "group-protocol-stack {} (gbp + gtp + gap + gsp)",
210        env!("CARGO_PKG_VERSION")
211    ))
212}
213
214// ============================================================================
215// MLS API
216// ============================================================================
217
218/// Creates a new MLS context. Returns the new handle, or `0` on failure.
219///
220/// # Safety
221/// `identity_ptr` MUST be valid for `identity_len` bytes.
222#[unsafe(no_mangle)]
223pub unsafe extern "C" fn gbp_mls_create(identity_ptr: *const u8, identity_len: usize) -> i32 {
224    clear_last_error();
225    let ident = unsafe { std::slice::from_raw_parts(identity_ptr, identity_len) };
226    match MlsContext::new_member(ident) {
227        Ok((ctx, kp)) => {
228            let id = mls().insert(ctx);
229            mls_bundles().map.lock().unwrap().insert(id, kp);
230            id
231        }
232        Err(e) => {
233            set_last_error(e);
234            0
235        }
236    }
237}
238
239/// Destroys an MLS context.
240#[unsafe(no_mangle)]
241pub extern "C" fn gbp_mls_destroy(h: i32) {
242    mls().remove(h);
243    mls_bundles().map.lock().unwrap().remove(&h);
244}
245
246/// Returns the current epoch of the MLS context, or `0` on failure.
247#[unsafe(no_mangle)]
248pub extern "C" fn gbp_mls_epoch(h: i32) -> u64 {
249    mls().get(h).map(|c| c.lock().unwrap().epoch()).unwrap_or(0)
250}
251
252/// Writes the 16-byte group identifier into `out16`.
253///
254/// # Safety
255/// `out16` MUST be valid for 16 bytes.
256#[unsafe(no_mangle)]
257pub unsafe extern "C" fn gbp_mls_group_id(h: i32, out16: *mut u8) -> bool {
258    clear_last_error();
259    let Some(ctx_arc) = mls().get(h) else {
260        set_last_error("invalid MLS handle");
261        return false;
262    };
263    let ctx = ctx_arc.lock().unwrap();
264    let gid = ctx.group_id_16();
265    unsafe { std::ptr::copy_nonoverlapping(gid.as_ptr(), out16, 16) };
266    true
267}
268
269/// Exports the TLS-serialised KeyPackage that can be used to invite this
270/// member into someone else's group.
271#[unsafe(no_mangle)]
272pub extern "C" fn gbp_mls_export_key_package(h: i32) -> GbpBuffer {
273    clear_last_error();
274    let bundles = mls_bundles().map.lock().unwrap();
275    let Some(b) = bundles.get(&h) else {
276        set_last_error("invalid MLS handle");
277        return GbpBuffer::empty();
278    };
279    match b.key_package().tls_serialize_detached() {
280        Ok(b) => GbpBuffer::from_vec(b),
281        Err(e) => {
282            set_last_error(format!("kp serialize: {e:?}"));
283            GbpBuffer::empty()
284        }
285    }
286}
287
288/// Invites the given KeyPackage into the local group. Returns the
289/// TLS-serialised Welcome bytes that the invitee must consume with
290/// [`gbp_mls_accept_welcome`].
291///
292/// # Safety
293/// `kp_ptr` MUST be valid for `kp_len` bytes.
294#[unsafe(no_mangle)]
295pub unsafe extern "C" fn gbp_mls_invite(h: i32, kp_ptr: *const u8, kp_len: usize) -> GbpBuffer {
296    clear_last_error();
297    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
298    let Some(ctx_arc) = mls().get(h) else {
299        set_last_error("invalid MLS handle");
300        return GbpBuffer::empty();
301    };
302    let mut ctx = ctx_arc.lock().unwrap();
303    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
304        Ok(v) => v,
305        Err(e) => {
306            set_last_error(format!("kp parse: {e:?}"));
307            return GbpBuffer::empty();
308        }
309    };
310    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
311        Ok(v) => v,
312        Err(e) => {
313            set_last_error(format!("kp validate: {e:?}"));
314            return GbpBuffer::empty();
315        }
316    };
317    match ctx.invite(&[validated]) {
318        Ok(welcome) => GbpBuffer::from_vec(welcome),
319        Err(e) => {
320            set_last_error(e);
321            GbpBuffer::empty()
322        }
323    }
324}
325
326/// Invites the given KeyPackage and returns BOTH the MLS Commit (which the
327/// caller MUST broadcast to existing members so they can advance their MLS
328/// epoch) and the Welcome (which the caller MUST unicast to the new joiner).
329///
330/// Buffer layout: `[u32-LE commit_len | commit_bytes | welcome_bytes]`. The
331/// total length minus 4 minus `commit_len` is the welcome length.
332///
333/// # Safety
334/// `kp_ptr` MUST be valid for `kp_len` bytes.
335#[unsafe(no_mangle)]
336pub unsafe extern "C" fn gbp_mls_invite_full(
337    h: i32,
338    kp_ptr: *const u8,
339    kp_len: usize,
340) -> GbpBuffer {
341    clear_last_error();
342    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
343    let Some(ctx_arc) = mls().get(h) else {
344        set_last_error("invalid MLS handle");
345        return GbpBuffer::empty();
346    };
347    let mut ctx = ctx_arc.lock().unwrap();
348    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
349        Ok(v) => v,
350        Err(e) => {
351            set_last_error(format!("kp parse: {e:?}"));
352            return GbpBuffer::empty();
353        }
354    };
355    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
356        Ok(v) => v,
357        Err(e) => {
358            set_last_error(format!("kp validate: {e:?}"));
359            return GbpBuffer::empty();
360        }
361    };
362    match ctx.invite_full(&[validated]) {
363        Ok((commit, welcome)) => {
364            let mut out = Vec::with_capacity(4 + commit.len() + welcome.len());
365            out.extend_from_slice(&(commit.len() as u32).to_le_bytes());
366            out.extend_from_slice(&commit);
367            out.extend_from_slice(&welcome);
368            GbpBuffer::from_vec(out)
369        }
370        Err(e) => {
371            set_last_error(e);
372            GbpBuffer::empty()
373        }
374    }
375}
376
377/// Removes the member at the given MLS LeafIndex and returns the
378/// TLS-serialised Commit. Caller MUST broadcast the Commit to remaining
379/// members so they advance their MLS epoch.
380#[unsafe(no_mangle)]
381pub extern "C" fn gbp_mls_remove(h: i32, leaf_index: u32) -> GbpBuffer {
382    clear_last_error();
383    let Some(ctx_arc) = mls().get(h) else {
384        set_last_error("invalid MLS handle");
385        return GbpBuffer::empty();
386    };
387    let mut ctx = ctx_arc.lock().unwrap();
388    match ctx.remove_members(&[leaf_index]) {
389        Ok(commit) => GbpBuffer::from_vec(commit),
390        Err(e) => {
391            set_last_error(e);
392            GbpBuffer::empty()
393        }
394    }
395}
396
397/// Applies a Commit (or staged Proposal) message to the local MLS group.
398/// Returns:
399///   1 — Commit applied (epoch advanced)
400///   2 — Application message (no-op for GBP)
401///   3 — Proposal staged
402///   4 — External message (no group state change)
403///   0 — failure (see `gbp_last_error`).
404///
405/// # Safety
406/// `msg_ptr` MUST be valid for `msg_len` bytes.
407#[unsafe(no_mangle)]
408pub unsafe extern "C" fn gbp_mls_process_message(
409    h: i32,
410    msg_ptr: *const u8,
411    msg_len: usize,
412) -> u32 {
413    clear_last_error();
414    let bytes = unsafe { std::slice::from_raw_parts(msg_ptr, msg_len) };
415    let Some(ctx_arc) = mls().get(h) else {
416        set_last_error("invalid MLS handle");
417        return 0;
418    };
419    let mut ctx = ctx_arc.lock().unwrap();
420    match ctx.process_message(bytes) {
421        Ok(ProcessedKind::Commit) => 1,
422        Ok(ProcessedKind::Application) => 2,
423        Ok(ProcessedKind::Proposal) => 3,
424        Ok(ProcessedKind::External) => 4,
425        Err(e) => {
426            set_last_error(e);
427            0
428        }
429    }
430}
431
432/// Merges any pending commit produced by `gbp_mls_invite_full` or
433/// `gbp_mls_remove`. Returns `true` on success, `false` on failure.
434#[unsafe(no_mangle)]
435pub extern "C" fn gbp_mls_finalize_commit(h: i32) -> bool {
436    clear_last_error();
437    let Some(ctx_arc) = mls().get(h) else {
438        set_last_error("invalid MLS handle");
439        return false;
440    };
441    let mut ctx = ctx_arc.lock().unwrap();
442    match ctx.finalize_pending_commit() {
443        Ok(()) => true,
444        Err(e) => {
445            set_last_error(e);
446            false
447        }
448    }
449}
450
451/// Discards any pending commit without applying it. Used on
452/// `ABORT_TRANSITION` to roll back to the pre-commit MLS state.
453#[unsafe(no_mangle)]
454pub extern "C" fn gbp_mls_clear_pending_commit(h: i32) -> bool {
455    clear_last_error();
456    let Some(ctx_arc) = mls().get(h) else {
457        set_last_error("invalid MLS handle");
458        return false;
459    };
460    let mut ctx = ctx_arc.lock().unwrap();
461    match ctx.clear_pending_commit() {
462        Ok(()) => true,
463        Err(e) => {
464            set_last_error(e);
465            false
466        }
467    }
468}
469
470/// Replaces the local group with the one described by the given Welcome.
471///
472/// # Safety
473/// `welcome_ptr` MUST be valid for `welcome_len` bytes.
474#[unsafe(no_mangle)]
475pub unsafe extern "C" fn gbp_mls_accept_welcome(
476    h: i32,
477    welcome_ptr: *const u8,
478    welcome_len: usize,
479) -> bool {
480    clear_last_error();
481    let bytes = unsafe { std::slice::from_raw_parts(welcome_ptr, welcome_len) };
482    let Some(ctx_arc) = mls().get(h) else {
483        set_last_error("invalid MLS handle");
484        return false;
485    };
486    let mut ctx = ctx_arc.lock().unwrap();
487    match ctx.accept_welcome(bytes) {
488        Ok(()) => true,
489        Err(e) => {
490            set_last_error(e);
491            false
492        }
493    }
494}
495
496// ============================================================================
497// GBP node API (the IP-like base layer)
498// ============================================================================
499
500/// Creates a new GBP node and returns its handle.
501///
502/// # Safety
503/// `group_id_16` MUST be valid for 16 bytes.
504#[unsafe(no_mangle)]
505pub unsafe extern "C" fn gbp_node_create(member_id: u32, group_id_16: *const u8) -> i32 {
506    clear_last_error();
507    let mut gid = [0u8; 16];
508    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
509    nodes().insert(GroupNode::new(member_id, gid))
510}
511
512/// Destroys a GBP node.
513#[unsafe(no_mangle)]
514pub extern "C" fn gbp_node_destroy(h: i32) {
515    nodes().remove(h);
516}
517
518/// Drives the node to `ACTIVE` as a creator.
519#[unsafe(no_mangle)]
520pub extern "C" fn gbp_node_bootstrap_creator(h: i32, epoch: u64) -> bool {
521    let Some(n_arc) = nodes().get(h) else { return false };
522    n_arc.lock().unwrap().bootstrap_as_creator(epoch);
523    true
524}
525
526/// Drives the node to `ACTIVE` as a joiner. `expected_first_tid` lets the
527/// joiner pre-arm pending transition state so that the first
528/// `EXECUTE_TRANSITION` after Welcome is accepted; pass `0` if the joiner
529/// recovered out-of-band and is already current.
530#[unsafe(no_mangle)]
531pub extern "C" fn gbp_node_bootstrap_joiner(h: i32, epoch: u64, expected_first_tid: u32) -> bool {
532    let Some(n_arc) = nodes().get(h) else { return false };
533    n_arc.lock().unwrap().bootstrap_as_joiner(epoch, expected_first_tid);
534    true
535}
536
537/// Returns the current `NodeState` encoded as `u32`.
538#[unsafe(no_mangle)]
539pub extern "C" fn gbp_node_state(h: i32) -> u32 {
540    nodes()
541        .get(h)
542        .map(|n| n.lock().unwrap().state as u32)
543        .unwrap_or(u32::MAX)
544}
545
546/// Returns the node's current epoch.
547#[unsafe(no_mangle)]
548pub extern "C" fn gbp_node_epoch(h: i32) -> u64 {
549    nodes().get(h).map(|n| n.lock().unwrap().current_epoch).unwrap_or(0)
550}
551
552/// Returns the node's last applied `transition_id`.
553#[unsafe(no_mangle)]
554pub extern "C" fn gbp_node_last_transition_id(h: i32) -> u32 {
555    nodes()
556        .get(h)
557        .map(|n| n.lock().unwrap().last_transition_id)
558        .unwrap_or(0)
559}
560
561/// Forcibly sets the node's `current_epoch` (intended for tests of late
562/// peers and `EPOCH_MISMATCH` recovery).
563#[unsafe(no_mangle)]
564pub extern "C" fn gbp_node_set_epoch(h: i32, epoch: u64) -> bool {
565    let Some(n_arc) = nodes().get(h) else { return false };
566    n_arc.lock().unwrap().current_epoch = epoch;
567    true
568}
569
570/// Applies an epoch transition locally.
571#[unsafe(no_mangle)]
572pub extern "C" fn gbp_node_apply_transition(h: i32, tid: u32) -> bool {
573    let Some(n_arc) = nodes().get(h) else { return false };
574    n_arc.lock().unwrap().apply_transition(tid);
575    true
576}
577
578/// Sends a control plane message on Stream 0.
579///
580/// The returned buffer layout is `[u32-LE target | wire]`.
581///
582/// # Safety
583/// `args_ptr` MUST be valid for `args_len` bytes.
584#[unsafe(no_mangle)]
585pub unsafe extern "C" fn gbp_node_send_control(
586    nh: i32,
587    mh: i32,
588    target: u32,
589    opcode: u16,
590    transition_id: u32,
591    request_id: u32,
592    args_ptr: *const u8,
593    args_len: usize,
594) -> GbpBuffer {
595    clear_last_error();
596    let op = match ControlOpcode::try_from(opcode) {
597        Ok(o) => o,
598        Err(_) => {
599            set_last_error(format!("bad opcode 0x{opcode:04X}"));
600            return GbpBuffer::empty();
601        }
602    };
603    let args = if args_len == 0 {
604        Vec::new()
605    } else {
606        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }.to_vec()
607    };
608    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
609    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
610        set_last_error("bad node/mls handle");
611        return GbpBuffer::empty();
612    };
613    let mut n = n_arc.lock().unwrap();
614    let mut m = m_arc.lock().unwrap();
615    match n.send_control(&mut *m, target, op, transition_id, request_id, args) {
616        Ok(of) => outbound_to_buffer(of),
617        Err(e) => {
618            set_last_error(e.to_string());
619            GbpBuffer::empty()
620        }
621    }
622}
623
624/// Feeds wire bytes to the node. Returns a JSON-encoded array of events.
625///
626/// # Safety
627/// `wire_ptr` MUST be valid for `wire_len` bytes.
628#[unsafe(no_mangle)]
629pub unsafe extern "C" fn gbp_node_on_wire(
630    nh: i32,
631    mh: i32,
632    wire_ptr: *const u8,
633    wire_len: usize,
634) -> *mut c_char {
635    clear_last_error();
636    let wire = unsafe { std::slice::from_raw_parts(wire_ptr, wire_len) };
637    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
638    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
639        set_last_error("bad node/mls handle");
640        return alloc_cstring("[]");
641    };
642    let mut n = n_arc.lock().unwrap();
643    let mut m = m_arc.lock().unwrap();
644    let events = match n.on_wire(&mut *m, wire) {
645        Ok(e) => e,
646        Err(e) => {
647            set_last_error(e.to_string());
648            return alloc_cstring("[]");
649        }
650    };
651    alloc_cstring(&events_to_json(&events))
652}
653
654/// Drains the queued events (without consuming any wire bytes).
655#[unsafe(no_mangle)]
656pub extern "C" fn gbp_node_drain_events(nh: i32) -> *mut c_char {
657    let Some(n_arc) = nodes().get(nh) else {
658        return alloc_cstring("[]");
659    };
660    alloc_cstring(&events_to_json(&n_arc.lock().unwrap().drain_events()))
661}
662
663fn outbound_to_buffer(of: OutboundFrame) -> GbpBuffer {
664    let mut out = Vec::with_capacity(4 + of.wire.len());
665    out.extend_from_slice(&of.to.to_le_bytes());
666    out.extend_from_slice(&of.wire);
667    GbpBuffer::from_vec(out)
668}
669
670// ============================================================================
671// GTP client API
672// ============================================================================
673
674/// Creates a stateful GTP client (idempotency tracking).
675#[unsafe(no_mangle)]
676pub extern "C" fn gtp_client_create() -> i32 {
677    gtps().insert(GtpClient::new())
678}
679
680/// Destroys a GTP client.
681#[unsafe(no_mangle)]
682pub extern "C" fn gtp_client_destroy(h: i32) {
683    gtps().remove(h);
684}
685
686/// Clears the client state. Intended for use after an epoch change.
687#[unsafe(no_mangle)]
688pub extern "C" fn gtp_client_reset(h: i32) {
689    if let Some(c) = gtps().get(h) {
690        c.lock().unwrap().reset();
691    }
692}
693
694/// Sends a text message via GTP.
695///
696/// # Safety
697/// `text_ptr` MUST be valid UTF-8 for `text_len` bytes.
698#[unsafe(no_mangle)]
699pub unsafe extern "C" fn gtp_client_send(
700    ch: i32,
701    nh: i32,
702    mh: i32,
703    target: u32,
704    message_id: u64,
705    text_ptr: *const u8,
706    text_len: usize,
707) -> GbpBuffer {
708    clear_last_error();
709    let text = unsafe { std::slice::from_raw_parts(text_ptr, text_len) };
710    let text = match std::str::from_utf8(text) {
711        Ok(s) => s,
712        Err(e) => {
713            set_last_error(format!("utf8: {e}"));
714            return GbpBuffer::empty();
715        }
716    };
717    let (c_arc, n_arc, m_arc) = (gtps().get(ch), nodes().get(nh), mls().get(mh));
718    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
719        set_last_error("bad handle");
720        return GbpBuffer::empty();
721    };
722    let mut c = c_arc.lock().unwrap();
723    let mut n = n_arc.lock().unwrap();
724    let mut m = m_arc.lock().unwrap();
725    match c.send(&mut *n, &mut *m, target, message_id, text) {
726        Ok(of) => outbound_to_buffer(of),
727        Err(e) => {
728            set_last_error(e.to_string());
729            GbpBuffer::empty()
730        }
731    }
732}
733
734/// Accepts a plaintext payload that the GBP layer surfaced via a
735/// `payload_received` event. Returns a JSON object of the form
736/// `{"status":"new|duplicate|error", ...}`.
737///
738/// `current_epoch` is the receiver node's current epoch — the client uses
739/// it to auto-reset its idempotency state when the epoch advances.
740///
741/// # Safety
742/// `pt_ptr` MUST be valid for `pt_len` bytes.
743#[unsafe(no_mangle)]
744pub unsafe extern "C" fn gtp_client_accept(
745    ch: i32,
746    current_epoch: u64,
747    pt_ptr: *const u8,
748    pt_len: usize,
749) -> *mut c_char {
750    clear_last_error();
751    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
752    let Some(c_arc) = gtps().get(ch) else {
753        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
754    };
755    let mut c = c_arc.lock().unwrap();
756    #[derive(Serialize)]
757    struct Out<'a> {
758        status: &'a str,
759        sender: Option<u32>,
760        message_id: Option<u64>,
761        text: Option<String>,
762        reason: Option<String>,
763    }
764    let out = match c.accept(pt, current_epoch) {
765        Ok(GtpAccept::New(m)) => Out {
766            status: "new",
767            sender: Some(m.sender_id),
768            message_id: Some(m.message_id),
769            text: Some(m.text().unwrap_or("<binary>").to_string()),
770            reason: None,
771        },
772        Ok(GtpAccept::Duplicate(m)) => Out {
773            status: "duplicate",
774            sender: Some(m.sender_id),
775            message_id: Some(m.message_id),
776            text: Some(m.text().unwrap_or("<binary>").to_string()),
777            reason: None,
778        },
779        Err(e) => Out {
780            status: "error",
781            sender: None,
782            message_id: None,
783            text: None,
784            reason: Some(e.to_string()),
785        },
786    };
787    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
788}
789
790// ============================================================================
791// GAP client API
792// ============================================================================
793
794/// Creates a stateful GAP client.
795#[unsafe(no_mangle)]
796pub extern "C" fn gap_client_create() -> i32 {
797    gaps().insert(GapClient::new())
798}
799
800/// Destroys a GAP client.
801#[unsafe(no_mangle)]
802pub extern "C" fn gap_client_destroy(h: i32) {
803    gaps().remove(h);
804}
805
806/// Clears the client state. Intended for use after an epoch change.
807#[unsafe(no_mangle)]
808pub extern "C" fn gap_client_reset(h: i32) {
809    if let Some(c) = gaps().get(h) {
810        c.lock().unwrap().reset();
811    }
812}
813
814/// Sends an Opus audio frame via GAP.
815///
816/// # Safety
817/// `opus_ptr` MUST be valid for `opus_len` bytes.
818#[unsafe(no_mangle)]
819pub unsafe extern "C" fn gap_client_send(
820    ch: i32,
821    nh: i32,
822    mh: i32,
823    target: u32,
824    media_source_id: u32,
825    rtp_timestamp: u64,
826    opus_ptr: *const u8,
827    opus_len: usize,
828) -> GbpBuffer {
829    clear_last_error();
830    let opus = unsafe { std::slice::from_raw_parts(opus_ptr, opus_len) }.to_vec();
831    let (c_arc, n_arc, m_arc) = (gaps().get(ch), nodes().get(nh), mls().get(mh));
832    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
833        set_last_error("bad handle");
834        return GbpBuffer::empty();
835    };
836    let mut c = c_arc.lock().unwrap();
837    let mut n = n_arc.lock().unwrap();
838    let mut m = m_arc.lock().unwrap();
839    match c.send(&mut *n, &mut *m, target, media_source_id, rtp_timestamp, opus) {
840        Ok(of) => outbound_to_buffer(of),
841        Err(e) => {
842            set_last_error(e.to_string());
843            GbpBuffer::empty()
844        }
845    }
846}
847
848/// Accepts a GAP audio payload.
849///
850/// # Safety
851/// `pt_ptr` MUST be valid for `pt_len` bytes.
852#[unsafe(no_mangle)]
853pub unsafe extern "C" fn gap_client_accept(
854    ch: i32,
855    current_epoch: u64,
856    pt_ptr: *const u8,
857    pt_len: usize,
858) -> *mut c_char {
859    clear_last_error();
860    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
861    let Some(c_arc) = gaps().get(ch) else {
862        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
863    };
864    let mut c = c_arc.lock().unwrap();
865    #[derive(Serialize)]
866    struct Out<'a> {
867        status: &'a str,
868        source: Option<u32>,
869        seq: Option<u32>,
870        bytes: Option<usize>,
871        reason: Option<String>,
872    }
873    let out = match c.accept(pt, current_epoch) {
874        Ok(GapAccept::New(p)) => Out {
875            status: "new",
876            source: Some(p.media_source_id),
877            seq: Some(p.rtp_sequence),
878            bytes: Some(p.opus_frame.len()),
879            reason: None,
880        },
881        Ok(GapAccept::Late(p)) => Out {
882            status: "late",
883            source: Some(p.media_source_id),
884            seq: Some(p.rtp_sequence),
885            bytes: Some(p.opus_frame.len()),
886            reason: None,
887        },
888        Err(e) => Out {
889            status: "error",
890            source: None,
891            seq: None,
892            bytes: None,
893            reason: Some(e.to_string()),
894        },
895    };
896    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
897}
898
899// ============================================================================
900// GSP client API
901// ============================================================================
902
903/// Creates a stateful GSP client.
904#[unsafe(no_mangle)]
905pub extern "C" fn gsp_client_create() -> i32 {
906    gsps().insert(GspClient::new())
907}
908
909/// Destroys a GSP client.
910#[unsafe(no_mangle)]
911pub extern "C" fn gsp_client_destroy(h: i32) {
912    gsps().remove(h);
913}
914
915/// Clears the client state. Intended for use after an epoch change.
916#[unsafe(no_mangle)]
917pub extern "C" fn gsp_client_reset(h: i32) {
918    if let Some(c) = gsps().get(h) {
919        c.lock().unwrap().reset();
920    }
921}
922
923/// Sends a GSP signal.
924#[unsafe(no_mangle)]
925pub extern "C" fn gsp_client_send(
926    ch: i32,
927    nh: i32,
928    mh: i32,
929    target: u32,
930    signal_type: u32,
931    role_claim: u32,
932    request_id: u32,
933) -> GbpBuffer {
934    clear_last_error();
935    let sig = match SignalType::try_from(signal_type) {
936        Ok(s) => s,
937        Err(_) => {
938            set_last_error(format!("bad signal {signal_type}"));
939            return GbpBuffer::empty();
940        }
941    };
942    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
943    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
944        set_last_error("bad handle");
945        return GbpBuffer::empty();
946    };
947    let mut c = c_arc.lock().unwrap();
948    let mut n = n_arc.lock().unwrap();
949    let mut m = m_arc.lock().unwrap();
950    match c.send(&mut *n, &mut *m, target, sig, role_claim, request_id) {
951        Ok(of) => outbound_to_buffer(of),
952        Err(e) => {
953            set_last_error(e.to_string());
954            GbpBuffer::empty()
955        }
956    }
957}
958
959/// Accepts a GSP signal payload.
960///
961/// `current_epoch` is the receiver node's current epoch — the client uses
962/// it to auto-reset its dedup state when the epoch advances.
963///
964/// # Safety
965/// `pt_ptr` MUST be valid for `pt_len` bytes.
966#[unsafe(no_mangle)]
967pub unsafe extern "C" fn gsp_client_accept(
968    ch: i32,
969    current_epoch: u64,
970    pt_ptr: *const u8,
971    pt_len: usize,
972) -> *mut c_char {
973    clear_last_error();
974    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
975    let Some(c_arc) = gsps().get(ch) else {
976        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
977    };
978    let mut c = c_arc.lock().unwrap();
979    #[derive(Serialize)]
980    struct Out<'a> {
981        status: &'a str,
982        signal: Option<&'a str>,
983        signal_code: Option<u32>,
984        sender: Option<u32>,
985        role_claim: Option<u32>,
986        request_id: Option<u32>,
987        reason: Option<String>,
988    }
989    let out = match c.accept(pt, current_epoch) {
990        Ok(GspAccept { signal, sender_id, role_claim, request_id }) => Out {
991            status: "new",
992            signal: Some(signal.name()),
993            signal_code: Some(signal as u32),
994            sender: Some(sender_id),
995            role_claim: Some(role_claim),
996            request_id: Some(request_id),
997            reason: None,
998        },
999        Err(e) => Out {
1000            status: "error",
1001            signal: None,
1002            signal_code: None,
1003            sender: None,
1004            role_claim: None,
1005            request_id: None,
1006            reason: Some(e.to_string()),
1007        },
1008    };
1009    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1010}
1011
1012// ============================================================================
1013// Codec helpers (used for tests that need malformed frames)
1014// ============================================================================
1015
1016/// CBOR-encodes a [`gbp::GbpFrame`] with an arbitrary version byte.
1017///
1018/// # Safety
1019/// Every pointer MUST be valid for the corresponding declared length.
1020#[unsafe(no_mangle)]
1021pub unsafe extern "C" fn gbp_frame_encode_v(
1022    version: u8,
1023    group_id_16: *const u8,
1024    epoch: u64,
1025    transition_id: u32,
1026    stream_type: u32,
1027    stream_id: u32,
1028    flags: u16,
1029    sequence_no: u32,
1030    payload_ptr: *const u8,
1031    payload_len: usize,
1032) -> GbpBuffer {
1033    clear_last_error();
1034    let mut gid = [0u8; 16];
1035    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
1036    let st_u8 = StreamType::try_from(stream_type).map(|s| s as u8).unwrap_or(stream_type as u8);
1037    let payload = unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) }.to_vec();
1038    let frame = gbp_stack::gbp::GbpFrame {
1039        version,
1040        group_id: serde_bytes::ByteBuf::from(gid.to_vec()),
1041        epoch,
1042        transition_id,
1043        stream_type: st_u8,
1044        stream_id,
1045        flags,
1046        sequence_no,
1047        payload_size: payload.len() as u32,
1048        encrypted_payload: serde_bytes::ByteBuf::from(payload),
1049    };
1050    GbpBuffer::from_vec(frame.to_cbor())
1051}
1052
1053/// Returns a CBOR-encoded `ErrorObject` for the given code.
1054#[unsafe(no_mangle)]
1055pub extern "C" fn gbp_error_lookup(code: u16) -> GbpBuffer {
1056    use gbp_stack::core::errors::ErrorSpec;
1057    match ErrorSpec::lookup(code) {
1058        Some(spec) => GbpBuffer::from_vec(ErrorObject::from_spec(spec, spec.name).to_cbor()),
1059        None => {
1060            set_last_error(format!("unknown error code 0x{code:04X}"));
1061            GbpBuffer::empty()
1062        }
1063    }
1064}
1065
1066#[allow(dead_code)]
1067fn _link(_f: &GbpFrame, _l: StreamLabel) {}
1068
1069// ============================================================================
1070// Event JSON
1071// ============================================================================
1072
1073#[derive(Serialize)]
1074#[serde(tag = "kind", rename_all = "snake_case")]
1075enum EventDto<'a> {
1076    StateChanged {
1077        from: String,
1078        to: String,
1079    },
1080    PayloadReceived {
1081        stream_type: &'a str,
1082        stream_type_code: u32,
1083        stream_id: u32,
1084        sequence_no: u32,
1085        flags: u16,
1086        plaintext_b64: String,
1087    },
1088    Control {
1089        from: u32,
1090        opcode: &'a str,
1091        opcode_code: u16,
1092        transition_id: u32,
1093        request_id: u32,
1094        args_b64: String,
1095    },
1096    Error {
1097        code: u16,
1098        code_hex: String,
1099        class: u8,
1100        retryable: bool,
1101        fatal: bool,
1102        reason: String,
1103    },
1104    EpochAdvanced {
1105        epoch: u64,
1106        transition_id: u32,
1107    },
1108}
1109
1110fn b64(b: &[u8]) -> String {
1111    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1112    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
1113    let mut i = 0;
1114    while i + 3 <= b.len() {
1115        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8) | (b[i + 2] as u32);
1116        out.push(A[(n >> 18) as usize & 0x3F] as char);
1117        out.push(A[(n >> 12) as usize & 0x3F] as char);
1118        out.push(A[(n >> 6) as usize & 0x3F] as char);
1119        out.push(A[n as usize & 0x3F] as char);
1120        i += 3;
1121    }
1122    let rem = b.len() - i;
1123    if rem == 1 {
1124        let n = (b[i] as u32) << 16;
1125        out.push(A[(n >> 18) as usize & 0x3F] as char);
1126        out.push(A[(n >> 12) as usize & 0x3F] as char);
1127        out.push('=');
1128        out.push('=');
1129    } else if rem == 2 {
1130        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8);
1131        out.push(A[(n >> 18) as usize & 0x3F] as char);
1132        out.push(A[(n >> 12) as usize & 0x3F] as char);
1133        out.push(A[(n >> 6) as usize & 0x3F] as char);
1134        out.push('=');
1135    }
1136    out
1137}
1138
1139fn dto<'a>(e: &'a Event) -> EventDto<'a> {
1140    match e {
1141        Event::StateChanged { from, to } => EventDto::StateChanged {
1142            from: from.to_string(),
1143            to: to.to_string(),
1144        },
1145        Event::PayloadReceived(DeliveredPayload {
1146            stream_type,
1147            stream_id,
1148            sequence_no,
1149            flags,
1150            plaintext,
1151        }) => EventDto::PayloadReceived {
1152            stream_type: match stream_type {
1153                StreamType::Control => "control",
1154                StreamType::Audio => "audio",
1155                StreamType::Text => "text",
1156                StreamType::Signal => "signal",
1157            },
1158            stream_type_code: *stream_type as u32,
1159            stream_id: *stream_id,
1160            sequence_no: *sequence_no,
1161            flags: *flags,
1162            plaintext_b64: b64(plaintext),
1163        },
1164        Event::Control { from, opcode, transition_id, request_id, args } => EventDto::Control {
1165            from: *from,
1166            opcode: opcode.name(),
1167            opcode_code: *opcode as u16,
1168            transition_id: *transition_id,
1169            request_id: *request_id,
1170            args_b64: b64(args),
1171        },
1172        Event::Error { code, class, retryable, fatal, reason } => EventDto::Error {
1173            code: *code,
1174            code_hex: format!("0x{code:04X}"),
1175            class: *class as u8,
1176            retryable: *retryable,
1177            fatal: *fatal,
1178            reason: reason.clone(),
1179        },
1180        Event::EpochAdvanced { epoch, transition_id } => EventDto::EpochAdvanced {
1181            epoch: *epoch,
1182            transition_id: *transition_id,
1183        },
1184    }
1185}
1186
1187fn events_to_json(events: &[Event]) -> String {
1188    let dtos: Vec<EventDto> = events.iter().map(dto).collect();
1189    serde_json::to_string(&dtos).unwrap_or_else(|_| "[]".to_string())
1190}
1191
1192#[allow(dead_code)]
1193const _STATES: [NodeState; 7] = [
1194    NodeState::Idle,
1195    NodeState::Connecting,
1196    NodeState::EstablishingGroup,
1197    NodeState::Active,
1198    NodeState::Resyncing,
1199    NodeState::Failed,
1200    NodeState::Closed,
1201];
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::b64;
1206
1207    #[test]
1208    fn b64_empty() {
1209        assert_eq!(b64(b""), "");
1210    }
1211
1212    #[test]
1213    fn b64_single_byte() {
1214        // 0xFF = "255" → base64: b"255" → "//8="
1215        // 0xFF >> 2 = 63 = '/', (0xFF & 0x03) << 4 = 60 = '8', pad = "=="
1216        // "255" in base64: first char = table[(255 >> 2)] = table[63] = '/'
1217        // second char = table[((255 & 3) << 4) | 0] = table[60] = '8'
1218        // pad = "==" → "//8="
1219        // Actually let me just check with known vectors.
1220        let s = b64(b"f");
1221        // "f" = 0x66 = 102. chars: table[102>>2=25]='Z', table[((102&3)<<4)=32]='g', pad="==" → "Zg=="
1222        assert_eq!(s, "Zg==");
1223    }
1224
1225    #[test]
1226    fn b64_two_bytes() {
1227        let s = b64(b"fo");
1228        // "fo" → 0x66, 0x6F. n = (0x66<<16)|(0x6F<<8)|0 = 0x666F00
1229        // c1 = table[n>>18] = table[0x666F00>>18=1] = 'B' ... let me just use a known-vector.
1230        // Actually "fo" in base64 is "Zm8="  (0x66='Z', combined='m8', pad='=')
1231        assert_eq!(s, "Zm8=");
1232    }
1233
1234    #[test]
1235    fn b64_three_bytes() {
1236        let s = b64(b"foo");
1237        // "foo" in standard base64 is "Zm9v"
1238        assert_eq!(s, "Zm9v");
1239    }
1240
1241    #[test]
1242    fn b64_known_vectors() {
1243        // Independently verified against RFC 4648 test vectors and Python base64.
1244        assert_eq!(b64(b""), "");
1245        assert_eq!(b64(b"f"), "Zg==");
1246        assert_eq!(b64(b"fo"), "Zm8=");
1247        assert_eq!(b64(b"foo"), "Zm9v");
1248        assert_eq!(b64(b"foob"), "Zm9vYg==");
1249        assert_eq!(b64(b"fooba"), "Zm9vYmE=");
1250        assert_eq!(b64(b"foobar"), "Zm9vYmFy");
1251    }
1252
1253    #[test]
1254    fn b64_padding_roundtrip() {
1255        // Verify padding is correct by checking every single-byte input 0..255
1256        for b in 0u8..=255 {
1257            let input = [b];
1258            let enc = b64(&input);
1259            // Every 1-byte base64 string is exactly 4 chars.
1260            assert_eq!(enc.len(), 4, "len mismatch for 0x{b:02X}: {enc}");
1261            // The last two chars must be padding.
1262            assert!(enc.ends_with("=="), "missing padding for 0x{b:02X}: {enc}");
1263        }
1264    }
1265}