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//! * **SFrame** (`gbp_sframe_*`) — E2EE for GAP audio frames.
13//!
14//! Conventions:
15//!
16//! * **Handle-based** — every long-lived object lives in a Rust-side
17//!   registry keyed by an `i32` handle.
18//! * **GbpBuffer** — binary blobs are returned as `(ptr, len, cap)` triples
19//!   and MUST be released with `gbp_buffer_free`.
20//! * **Owned C-string** — text values are returned as owned `*mut c_char`
21//!   and MUST be released with `gbp_string_free`.
22//! * **Last error** — every fallible call writes to a thread-local error
23//!   slot that callers can read via `gbp_last_error`.
24
25#![allow(unsafe_op_in_unsafe_fn)]
26
27use gbp_stack::core::{ControlOpcode, NodeState, PayloadCodec, SignalType, StreamType};
28use gbp_stack::{
29    CipherSuite, DeliveredPayload, ErrorObject, Event, GapAccept, GapClient, GbpFrame, GroupNode,
30    GspAccept, GspClient, GtpAccept, GtpClient, MlsContext, OutboundFrame, ProcessedKind,
31    SFrameDecryptor, SFrameEncryptor, SFrameSession, StreamLabel,
32};
33use openmls::prelude::tls_codec::Serialize as _;
34use openmls::prelude::*;
35use serde::Serialize;
36use std::cell::RefCell;
37use std::collections::HashMap;
38use std::ffi::{CString, c_char};
39use std::sync::atomic::{AtomicI32, Ordering};
40use std::sync::{Arc, Mutex};
41
42// ============================================================================
43// Buffer / string types (FFI memory protocol)
44// ============================================================================
45
46/// Binary buffer produced by Rust. The caller MUST release it via
47/// [`gbp_buffer_free`].
48#[repr(C)]
49pub struct GbpBuffer {
50    /// Pointer to the bytes (may be null when `len == 0`).
51    pub ptr: *mut u8,
52    /// Current length in bytes.
53    pub len: usize,
54    /// Capacity used when reconstructing the underlying `Vec` on free.
55    pub cap: usize,
56}
57
58impl GbpBuffer {
59    fn empty() -> Self {
60        Self {
61            ptr: std::ptr::null_mut(),
62            len: 0,
63            cap: 0,
64        }
65    }
66    fn from_vec(mut v: Vec<u8>) -> Self {
67        let ptr = v.as_mut_ptr();
68        let len = v.len();
69        let cap = v.capacity();
70        std::mem::forget(v);
71        Self { ptr, len, cap }
72    }
73}
74
75/// Releases a [`GbpBuffer`].
76///
77/// # Safety
78/// `buf` MUST have been returned by one of the `gbp_*` FFI functions and
79/// MUST NOT have been freed already.
80#[unsafe(no_mangle)]
81pub unsafe extern "C" fn gbp_buffer_free(buf: GbpBuffer) {
82    if buf.ptr.is_null() {
83        return;
84    }
85    unsafe {
86        let _ = Vec::from_raw_parts(buf.ptr, buf.len, buf.cap);
87    }
88}
89
90/// Releases a string previously returned by an FFI function.
91///
92/// # Safety
93/// `ptr` MUST have been returned by one of the `gbp_*` FFI functions.
94#[unsafe(no_mangle)]
95pub unsafe extern "C" fn gbp_string_free(ptr: *mut c_char) {
96    if ptr.is_null() {
97        return;
98    }
99    unsafe {
100        let _ = CString::from_raw(ptr);
101    }
102}
103
104fn alloc_cstring(s: &str) -> *mut c_char {
105    CString::new(s.as_bytes())
106        .unwrap_or_else(|_| CString::new(s.replace('\0', "?")).unwrap())
107        .into_raw()
108}
109
110// ============================================================================
111// Last-error machinery
112// ============================================================================
113
114thread_local! {
115    static LAST_ERROR: RefCell<String> = const { RefCell::new(String::new()) };
116}
117
118fn set_last_error(e: impl ToString) {
119    LAST_ERROR.with(|s| *s.borrow_mut() = e.to_string());
120}
121
122fn clear_last_error() {
123    LAST_ERROR.with(|s| s.borrow_mut().clear());
124}
125
126/// Returns the text of the last error, or an empty string if none.
127#[unsafe(no_mangle)]
128pub extern "C" fn gbp_last_error() -> *mut c_char {
129    LAST_ERROR.with(|s| alloc_cstring(&s.borrow()))
130}
131
132// ============================================================================
133// Handle registries
134// ============================================================================
135
136macro_rules! registry {
137    ($vis:vis $name:ident<$t:ty>) => {
138        $vis struct $name {
139            next: AtomicI32,
140            map: Mutex<HashMap<i32, Arc<Mutex<$t>>>>,
141        }
142        impl $name {
143            fn new() -> Self {
144                Self { next: AtomicI32::new(1), map: Mutex::new(HashMap::new()) }
145            }
146            fn insert(&self, v: $t) -> i32 {
147                let id = self.next.fetch_add(1, Ordering::Relaxed);
148                self.map.lock().unwrap().insert(id, Arc::new(Mutex::new(v)));
149                id
150            }
151            fn remove(&self, id: i32) {
152                self.map.lock().unwrap().remove(&id);
153            }
154            fn get(&self, id: i32) -> Option<Arc<Mutex<$t>>> {
155                self.map.lock().unwrap().get(&id).cloned()
156            }
157        }
158    };
159}
160
161registry!(MlsRegistry<MlsContext>);
162registry!(NodeRegistry<GroupNode>);
163registry!(GtpRegistry<GtpClient>);
164registry!(GapRegistry<GapClient>);
165registry!(GspRegistry<GspClient>);
166registry!(SFrameSessionRegistry<SFrameDecryptor>);
167registry!(SFrameEncryptorRegistry<SFrameEncryptor>);
168
169struct MlsBundles {
170    map: Mutex<HashMap<i32, KeyPackageBundle>>,
171}
172impl MlsBundles {
173    fn new() -> Self {
174        Self {
175            map: Mutex::new(HashMap::new()),
176        }
177    }
178}
179
180fn mls() -> &'static MlsRegistry {
181    use std::sync::OnceLock;
182    static R: OnceLock<MlsRegistry> = OnceLock::new();
183    R.get_or_init(MlsRegistry::new)
184}
185fn mls_bundles() -> &'static MlsBundles {
186    use std::sync::OnceLock;
187    static R: OnceLock<MlsBundles> = OnceLock::new();
188    R.get_or_init(MlsBundles::new)
189}
190fn nodes() -> &'static NodeRegistry {
191    use std::sync::OnceLock;
192    static R: OnceLock<NodeRegistry> = OnceLock::new();
193    R.get_or_init(NodeRegistry::new)
194}
195fn gtps() -> &'static GtpRegistry {
196    use std::sync::OnceLock;
197    static R: OnceLock<GtpRegistry> = OnceLock::new();
198    R.get_or_init(GtpRegistry::new)
199}
200fn gaps() -> &'static GapRegistry {
201    use std::sync::OnceLock;
202    static R: OnceLock<GapRegistry> = OnceLock::new();
203    R.get_or_init(GapRegistry::new)
204}
205fn gsps() -> &'static GspRegistry {
206    use std::sync::OnceLock;
207    static R: OnceLock<GspRegistry> = OnceLock::new();
208    R.get_or_init(GspRegistry::new)
209}
210fn sframe_sessions() -> &'static SFrameSessionRegistry {
211    use std::sync::OnceLock;
212    static R: OnceLock<SFrameSessionRegistry> = OnceLock::new();
213    R.get_or_init(SFrameSessionRegistry::new)
214}
215fn sframe_encryptors() -> &'static SFrameEncryptorRegistry {
216    use std::sync::OnceLock;
217    static R: OnceLock<SFrameEncryptorRegistry> = OnceLock::new();
218    R.get_or_init(SFrameEncryptorRegistry::new)
219}
220
221// ============================================================================
222// Version
223// ============================================================================
224
225/// Returns the FFI library version with a short summary of the layers.
226#[unsafe(no_mangle)]
227pub extern "C" fn gbp_version() -> *mut c_char {
228    alloc_cstring(&format!(
229        "group-protocol-stack {} (gbp + gtp + gap + gsp)",
230        env!("CARGO_PKG_VERSION")
231    ))
232}
233
234// ============================================================================
235// MLS API
236// ============================================================================
237
238/// Creates a new MLS context. Returns the new handle, or `0` on failure.
239///
240/// # Safety
241/// `identity_ptr` MUST be valid for `identity_len` bytes.
242#[unsafe(no_mangle)]
243pub unsafe extern "C" fn gbp_mls_create(identity_ptr: *const u8, identity_len: usize) -> i32 {
244    clear_last_error();
245    let ident = unsafe { std::slice::from_raw_parts(identity_ptr, identity_len) };
246    match MlsContext::new_member(ident) {
247        Ok((ctx, kp)) => {
248            let id = mls().insert(ctx);
249            mls_bundles().map.lock().unwrap().insert(id, kp);
250            id
251        }
252        Err(e) => {
253            set_last_error(e);
254            0
255        }
256    }
257}
258
259/// Destroys an MLS context.
260#[unsafe(no_mangle)]
261pub extern "C" fn gbp_mls_destroy(h: i32) {
262    mls().remove(h);
263    mls_bundles().map.lock().unwrap().remove(&h);
264}
265
266/// Returns the current epoch of the MLS context, or `0` on failure.
267#[unsafe(no_mangle)]
268pub extern "C" fn gbp_mls_epoch(h: i32) -> u64 {
269    mls().get(h).map(|c| c.lock().unwrap().epoch()).unwrap_or(0)
270}
271
272/// Writes the 16-byte group identifier into `out16`.
273///
274/// # Safety
275/// `out16` MUST be valid for 16 bytes.
276#[unsafe(no_mangle)]
277pub unsafe extern "C" fn gbp_mls_group_id(h: i32, out16: *mut u8) -> bool {
278    clear_last_error();
279    let Some(ctx_arc) = mls().get(h) else {
280        set_last_error("invalid MLS handle");
281        return false;
282    };
283    let ctx = ctx_arc.lock().unwrap();
284    let gid = ctx.group_id_16();
285    unsafe { std::ptr::copy_nonoverlapping(gid.as_ptr(), out16, 16) };
286    true
287}
288
289/// Exports the TLS-serialised KeyPackage that can be used to invite this
290/// member into someone else's group.
291#[unsafe(no_mangle)]
292pub extern "C" fn gbp_mls_export_key_package(h: i32) -> GbpBuffer {
293    clear_last_error();
294    let bundles = mls_bundles().map.lock().unwrap();
295    let Some(b) = bundles.get(&h) else {
296        set_last_error("invalid MLS handle");
297        return GbpBuffer::empty();
298    };
299    match b.key_package().tls_serialize_detached() {
300        Ok(b) => GbpBuffer::from_vec(b),
301        Err(e) => {
302            set_last_error(format!("kp serialize: {e:?}"));
303            GbpBuffer::empty()
304        }
305    }
306}
307
308/// Invites the given KeyPackage into the local group. Returns the
309/// TLS-serialised Welcome bytes that the invitee must consume with
310/// [`gbp_mls_accept_welcome`].
311///
312/// # Safety
313/// `kp_ptr` MUST be valid for `kp_len` bytes.
314#[unsafe(no_mangle)]
315pub unsafe extern "C" fn gbp_mls_invite(h: i32, kp_ptr: *const u8, kp_len: usize) -> GbpBuffer {
316    clear_last_error();
317    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
318    let Some(ctx_arc) = mls().get(h) else {
319        set_last_error("invalid MLS handle");
320        return GbpBuffer::empty();
321    };
322    let mut ctx = ctx_arc.lock().unwrap();
323    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
324        Ok(v) => v,
325        Err(e) => {
326            set_last_error(format!("kp parse: {e:?}"));
327            return GbpBuffer::empty();
328        }
329    };
330    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
331        Ok(v) => v,
332        Err(e) => {
333            set_last_error(format!("kp validate: {e:?}"));
334            return GbpBuffer::empty();
335        }
336    };
337    match ctx.invite(&[validated]) {
338        Ok(welcome) => GbpBuffer::from_vec(welcome),
339        Err(e) => {
340            set_last_error(e);
341            GbpBuffer::empty()
342        }
343    }
344}
345
346/// Invites the given KeyPackage and returns BOTH the MLS Commit (which the
347/// caller MUST broadcast to existing members so they can advance their MLS
348/// epoch) and the Welcome (which the caller MUST unicast to the new joiner).
349///
350/// Buffer layout: `[u32-LE commit_len | commit_bytes | welcome_bytes]`. The
351/// total length minus 4 minus `commit_len` is the welcome length.
352///
353/// # Safety
354/// `kp_ptr` MUST be valid for `kp_len` bytes.
355#[unsafe(no_mangle)]
356pub unsafe extern "C" fn gbp_mls_invite_full(
357    h: i32,
358    kp_ptr: *const u8,
359    kp_len: usize,
360) -> GbpBuffer {
361    clear_last_error();
362    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
363    let Some(ctx_arc) = mls().get(h) else {
364        set_last_error("invalid MLS handle");
365        return GbpBuffer::empty();
366    };
367    let mut ctx = ctx_arc.lock().unwrap();
368    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
369        Ok(v) => v,
370        Err(e) => {
371            set_last_error(format!("kp parse: {e:?}"));
372            return GbpBuffer::empty();
373        }
374    };
375    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
376        Ok(v) => v,
377        Err(e) => {
378            set_last_error(format!("kp validate: {e:?}"));
379            return GbpBuffer::empty();
380        }
381    };
382    match ctx.invite_full(&[validated]) {
383        Ok((commit, welcome)) => {
384            let mut out = Vec::with_capacity(4 + commit.len() + welcome.len());
385            out.extend_from_slice(&(commit.len() as u32).to_le_bytes());
386            out.extend_from_slice(&commit);
387            out.extend_from_slice(&welcome);
388            GbpBuffer::from_vec(out)
389        }
390        Err(e) => {
391            set_last_error(e);
392            GbpBuffer::empty()
393        }
394    }
395}
396
397/// Removes the member at the given MLS LeafIndex and returns the
398/// TLS-serialised Commit. Caller MUST broadcast the Commit to remaining
399/// members so they advance their MLS epoch.
400#[unsafe(no_mangle)]
401pub extern "C" fn gbp_mls_remove(h: i32, leaf_index: u32) -> GbpBuffer {
402    clear_last_error();
403    let Some(ctx_arc) = mls().get(h) else {
404        set_last_error("invalid MLS handle");
405        return GbpBuffer::empty();
406    };
407    let mut ctx = ctx_arc.lock().unwrap();
408    match ctx.remove_members(&[leaf_index]) {
409        Ok(commit) => GbpBuffer::from_vec(commit),
410        Err(e) => {
411            set_last_error(e);
412            GbpBuffer::empty()
413        }
414    }
415}
416
417/// Applies a Commit (or staged Proposal) message to the local MLS group.
418/// Returns:
419///   1 — Commit applied (epoch advanced)
420///   2 — Application message (no-op for GBP)
421///   3 — Proposal staged
422///   4 — External message (no group state change)
423///   0 — failure (see `gbp_last_error`).
424///
425/// # Safety
426/// `msg_ptr` MUST be valid for `msg_len` bytes.
427#[unsafe(no_mangle)]
428pub unsafe extern "C" fn gbp_mls_process_message(
429    h: i32,
430    msg_ptr: *const u8,
431    msg_len: usize,
432) -> u32 {
433    clear_last_error();
434    let bytes = unsafe { std::slice::from_raw_parts(msg_ptr, msg_len) };
435    let Some(ctx_arc) = mls().get(h) else {
436        set_last_error("invalid MLS handle");
437        return 0;
438    };
439    let mut ctx = ctx_arc.lock().unwrap();
440    match ctx.process_message(bytes) {
441        Ok(ProcessedKind::Commit) => 1,
442        Ok(ProcessedKind::Application) => 2,
443        Ok(ProcessedKind::Proposal) => 3,
444        Ok(ProcessedKind::External) => 4,
445        Err(e) => {
446            set_last_error(e);
447            0
448        }
449    }
450}
451
452/// Merges any pending commit produced by `gbp_mls_invite_full` or
453/// `gbp_mls_remove`. Returns `true` on success, `false` on failure.
454#[unsafe(no_mangle)]
455pub extern "C" fn gbp_mls_finalize_commit(h: i32) -> bool {
456    clear_last_error();
457    let Some(ctx_arc) = mls().get(h) else {
458        set_last_error("invalid MLS handle");
459        return false;
460    };
461    let mut ctx = ctx_arc.lock().unwrap();
462    match ctx.finalize_pending_commit() {
463        Ok(()) => true,
464        Err(e) => {
465            set_last_error(e);
466            false
467        }
468    }
469}
470
471/// Discards any pending commit without applying it. Used on
472/// `ABORT_TRANSITION` to roll back to the pre-commit MLS state.
473#[unsafe(no_mangle)]
474pub extern "C" fn gbp_mls_clear_pending_commit(h: i32) -> bool {
475    clear_last_error();
476    let Some(ctx_arc) = mls().get(h) else {
477        set_last_error("invalid MLS handle");
478        return false;
479    };
480    let mut ctx = ctx_arc.lock().unwrap();
481    match ctx.clear_pending_commit() {
482        Ok(()) => true,
483        Err(e) => {
484            set_last_error(e);
485            false
486        }
487    }
488}
489
490/// Replaces the local group with the one described by the given Welcome.
491///
492/// # Safety
493/// `welcome_ptr` MUST be valid for `welcome_len` bytes.
494#[unsafe(no_mangle)]
495pub unsafe extern "C" fn gbp_mls_accept_welcome(
496    h: i32,
497    welcome_ptr: *const u8,
498    welcome_len: usize,
499) -> bool {
500    clear_last_error();
501    let bytes = unsafe { std::slice::from_raw_parts(welcome_ptr, welcome_len) };
502    let Some(ctx_arc) = mls().get(h) else {
503        set_last_error("invalid MLS handle");
504        return false;
505    };
506    let mut ctx = ctx_arc.lock().unwrap();
507    match ctx.accept_welcome(bytes) {
508        Ok(()) => true,
509        Err(e) => {
510            set_last_error(e);
511            false
512        }
513    }
514}
515
516// ============================================================================
517// GBP node API (the IP-like base layer)
518// ============================================================================
519
520/// Creates a new GBP node and returns its handle.
521///
522/// # Safety
523/// `group_id_16` MUST be valid for 16 bytes.
524#[unsafe(no_mangle)]
525pub unsafe extern "C" fn gbp_node_create(member_id: u32, group_id_16: *const u8) -> i32 {
526    clear_last_error();
527    let mut gid = [0u8; 16];
528    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
529    nodes().insert(GroupNode::new(member_id, gid))
530}
531
532/// Destroys a GBP node.
533#[unsafe(no_mangle)]
534pub extern "C" fn gbp_node_destroy(h: i32) {
535    nodes().remove(h);
536}
537
538/// Drives the node to `ACTIVE` as a creator.
539#[unsafe(no_mangle)]
540pub extern "C" fn gbp_node_bootstrap_creator(h: i32, epoch: u64) -> bool {
541    let Some(n_arc) = nodes().get(h) else {
542        return false;
543    };
544    n_arc.lock().unwrap().bootstrap_as_creator(epoch);
545    true
546}
547
548/// Drives the node to `ACTIVE` as a joiner. `expected_first_tid` lets the
549/// joiner pre-arm pending transition state so that the first
550/// `EXECUTE_TRANSITION` after Welcome is accepted; pass `0` if the joiner
551/// recovered out-of-band and is already current.
552#[unsafe(no_mangle)]
553pub extern "C" fn gbp_node_bootstrap_joiner(h: i32, epoch: u64, expected_first_tid: u32) -> bool {
554    let Some(n_arc) = nodes().get(h) else {
555        return false;
556    };
557    n_arc
558        .lock()
559        .unwrap()
560        .bootstrap_as_joiner(epoch, expected_first_tid);
561    true
562}
563
564/// Returns the current `NodeState` encoded as `u32`.
565#[unsafe(no_mangle)]
566pub extern "C" fn gbp_node_state(h: i32) -> u32 {
567    nodes()
568        .get(h)
569        .map(|n| n.lock().unwrap().state as u32)
570        .unwrap_or(u32::MAX)
571}
572
573/// Returns the node's current epoch.
574#[unsafe(no_mangle)]
575pub extern "C" fn gbp_node_epoch(h: i32) -> u64 {
576    nodes()
577        .get(h)
578        .map(|n| n.lock().unwrap().current_epoch)
579        .unwrap_or(0)
580}
581
582/// Returns the node's last applied `transition_id`.
583#[unsafe(no_mangle)]
584pub extern "C" fn gbp_node_last_transition_id(h: i32) -> u32 {
585    nodes()
586        .get(h)
587        .map(|n| n.lock().unwrap().last_transition_id)
588        .unwrap_or(0)
589}
590
591/// Forcibly sets the node's `current_epoch` (intended for tests of late
592/// peers and `EPOCH_MISMATCH` recovery).
593#[unsafe(no_mangle)]
594pub extern "C" fn gbp_node_set_epoch(h: i32, epoch: u64) -> bool {
595    let Some(n_arc) = nodes().get(h) else {
596        return false;
597    };
598    n_arc.lock().unwrap().current_epoch = epoch;
599    true
600}
601
602/// Applies an epoch transition locally.
603#[unsafe(no_mangle)]
604pub extern "C" fn gbp_node_apply_transition(h: i32, tid: u32) -> bool {
605    let Some(n_arc) = nodes().get(h) else {
606        return false;
607    };
608    n_arc.lock().unwrap().apply_transition(tid);
609    true
610}
611
612/// Sends a control plane message on Stream 0.
613///
614/// The returned buffer layout is `[u32-LE target | wire]`.
615///
616/// # Safety
617/// `args_ptr` MUST be valid for `args_len` bytes.
618#[unsafe(no_mangle)]
619pub unsafe extern "C" fn gbp_node_send_control(
620    nh: i32,
621    mh: i32,
622    target: u32,
623    opcode: u16,
624    transition_id: u32,
625    request_id: u32,
626    args_ptr: *const u8,
627    args_len: usize,
628) -> GbpBuffer {
629    clear_last_error();
630    let op = match ControlOpcode::try_from(opcode) {
631        Ok(o) => o,
632        Err(_) => {
633            set_last_error(format!("bad opcode 0x{opcode:04X}"));
634            return GbpBuffer::empty();
635        }
636    };
637    let args = if args_len == 0 {
638        Vec::new()
639    } else {
640        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }.to_vec()
641    };
642    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
643    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
644        set_last_error("bad node/mls handle");
645        return GbpBuffer::empty();
646    };
647    let mut n = n_arc.lock().unwrap();
648    let mut m = m_arc.lock().unwrap();
649    match n.send_control(&mut *m, target, op, transition_id, request_id, args) {
650        Ok(of) => outbound_to_buffer(of),
651        Err(e) => {
652            set_last_error(e.to_string());
653            GbpBuffer::empty()
654        }
655    }
656}
657
658/// Feeds wire bytes to the node. Returns a JSON-encoded array of events.
659///
660/// # Safety
661/// `wire_ptr` MUST be valid for `wire_len` bytes.
662#[unsafe(no_mangle)]
663pub unsafe extern "C" fn gbp_node_on_wire(
664    nh: i32,
665    mh: i32,
666    wire_ptr: *const u8,
667    wire_len: usize,
668) -> *mut c_char {
669    clear_last_error();
670    let wire = unsafe { std::slice::from_raw_parts(wire_ptr, wire_len) };
671    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
672    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
673        set_last_error("bad node/mls handle");
674        return alloc_cstring("[]");
675    };
676    let mut n = n_arc.lock().unwrap();
677    let mut m = m_arc.lock().unwrap();
678    let events = match n.on_wire(&mut *m, wire) {
679        Ok(e) => e,
680        Err(e) => {
681            set_last_error(e.to_string());
682            return alloc_cstring("[]");
683        }
684    };
685    alloc_cstring(&events_to_json(&events))
686}
687
688/// Drains the queued events (without consuming any wire bytes).
689#[unsafe(no_mangle)]
690pub extern "C" fn gbp_node_drain_events(nh: i32) -> *mut c_char {
691    let Some(n_arc) = nodes().get(nh) else {
692        return alloc_cstring("[]");
693    };
694    alloc_cstring(&events_to_json(&n_arc.lock().unwrap().drain_events()))
695}
696
697fn outbound_to_buffer(of: OutboundFrame) -> GbpBuffer {
698    let mut out = Vec::with_capacity(4 + of.wire.len());
699    out.extend_from_slice(&of.to.to_le_bytes());
700    out.extend_from_slice(&of.wire);
701    GbpBuffer::from_vec(out)
702}
703
704// ============================================================================
705// GTP client API
706// ============================================================================
707
708/// Creates a stateful GTP client (idempotency tracking).
709#[unsafe(no_mangle)]
710pub extern "C" fn gtp_client_create() -> i32 {
711    gtps().insert(GtpClient::new())
712}
713
714/// Destroys a GTP client.
715#[unsafe(no_mangle)]
716pub extern "C" fn gtp_client_destroy(h: i32) {
717    gtps().remove(h);
718}
719
720/// Clears the client state. Intended for use after an epoch change.
721#[unsafe(no_mangle)]
722pub extern "C" fn gtp_client_reset(h: i32) {
723    if let Some(c) = gtps().get(h) {
724        c.lock().unwrap().reset();
725    }
726}
727
728/// Sends a text message via GTP.
729///
730/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
731/// 2 = FlatBuffers. Unknown values fall back to CBOR.
732///
733/// # Safety
734/// `text_ptr` MUST be valid UTF-8 for `text_len` bytes.
735#[unsafe(no_mangle)]
736pub unsafe extern "C" fn gtp_client_send(
737    ch: i32,
738    nh: i32,
739    mh: i32,
740    target: u32,
741    message_id: u64,
742    text_ptr: *const u8,
743    text_len: usize,
744    codec: u8,
745) -> GbpBuffer {
746    clear_last_error();
747    let text = unsafe { std::slice::from_raw_parts(text_ptr, text_len) };
748    let text = match std::str::from_utf8(text) {
749        Ok(s) => s,
750        Err(e) => {
751            set_last_error(format!("utf8: {e}"));
752            return GbpBuffer::empty();
753        }
754    };
755    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
756    let (c_arc, n_arc, m_arc) = (gtps().get(ch), nodes().get(nh), mls().get(mh));
757    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
758        set_last_error("bad handle");
759        return GbpBuffer::empty();
760    };
761    let mut c = c_arc.lock().unwrap();
762    let mut n = n_arc.lock().unwrap();
763    let mut m = m_arc.lock().unwrap();
764    match c.send(&mut *n, &mut *m, target, message_id, text, codec) {
765        Ok(of) => outbound_to_buffer(of),
766        Err(e) => {
767            set_last_error(e.to_string());
768            GbpBuffer::empty()
769        }
770    }
771}
772
773/// Accepts a plaintext payload that the GBP layer surfaced via a
774/// `payload_received` event. Returns a JSON object of the form
775/// `{"status":"new|duplicate|error", ...}`.
776///
777/// `current_epoch` is the receiver node's current epoch — the client uses
778/// it to auto-reset its idempotency state when the epoch advances.
779/// `codec` must match the value from the `DeliveredPayload::codec` field
780/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
781///
782/// # Safety
783/// `pt_ptr` MUST be valid for `pt_len` bytes.
784#[unsafe(no_mangle)]
785pub unsafe extern "C" fn gtp_client_accept(
786    ch: i32,
787    current_epoch: u64,
788    pt_ptr: *const u8,
789    pt_len: usize,
790    codec: u8,
791) -> *mut c_char {
792    clear_last_error();
793    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
794    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
795    let Some(c_arc) = gtps().get(ch) else {
796        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
797    };
798    let mut c = c_arc.lock().unwrap();
799    #[derive(Serialize)]
800    struct Out<'a> {
801        status: &'a str,
802        sender: Option<u32>,
803        message_id: Option<u64>,
804        text: Option<String>,
805        reason: Option<String>,
806    }
807    let out = match c.accept(pt, current_epoch, codec) {
808        Ok(GtpAccept::New(m)) => Out {
809            status: "new",
810            sender: Some(m.sender_id),
811            message_id: Some(m.message_id),
812            text: Some(m.text().unwrap_or("<binary>").to_string()),
813            reason: None,
814        },
815        Ok(GtpAccept::Duplicate(m)) => Out {
816            status: "duplicate",
817            sender: Some(m.sender_id),
818            message_id: Some(m.message_id),
819            text: Some(m.text().unwrap_or("<binary>").to_string()),
820            reason: None,
821        },
822        Err(e) => Out {
823            status: "error",
824            sender: None,
825            message_id: None,
826            text: None,
827            reason: Some(e.to_string()),
828        },
829    };
830    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
831}
832
833// ============================================================================
834// GAP client API
835// ============================================================================
836
837/// Creates a stateful GAP client.
838#[unsafe(no_mangle)]
839pub extern "C" fn gap_client_create() -> i32 {
840    gaps().insert(GapClient::new())
841}
842
843/// Destroys a GAP client.
844#[unsafe(no_mangle)]
845pub extern "C" fn gap_client_destroy(h: i32) {
846    gaps().remove(h);
847}
848
849/// Clears the client state. Intended for use after an epoch change.
850#[unsafe(no_mangle)]
851pub extern "C" fn gap_client_reset(h: i32) {
852    if let Some(c) = gaps().get(h) {
853        c.lock().unwrap().reset();
854    }
855}
856
857/// Sends an Opus audio frame via GAP.
858///
859/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
860/// 2 = FlatBuffers. For audio use FlatBuffers (2) to minimize decode latency.
861///
862/// # Safety
863/// `opus_ptr` MUST be valid for `opus_len` bytes.
864#[unsafe(no_mangle)]
865pub unsafe extern "C" fn gap_client_send(
866    ch: i32,
867    nh: i32,
868    mh: i32,
869    target: u32,
870    media_source_id: u32,
871    rtp_timestamp: u64,
872    opus_ptr: *const u8,
873    opus_len: usize,
874    codec: u8,
875) -> GbpBuffer {
876    clear_last_error();
877    let opus = unsafe { std::slice::from_raw_parts(opus_ptr, opus_len) }.to_vec();
878    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
879    let (c_arc, n_arc, m_arc) = (gaps().get(ch), nodes().get(nh), mls().get(mh));
880    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
881        set_last_error("bad handle");
882        return GbpBuffer::empty();
883    };
884    let mut c = c_arc.lock().unwrap();
885    let mut n = n_arc.lock().unwrap();
886    let mut m = m_arc.lock().unwrap();
887    match c.send(
888        &mut *n,
889        &mut *m,
890        target,
891        media_source_id,
892        rtp_timestamp,
893        opus,
894        codec,
895    ) {
896        Ok(of) => outbound_to_buffer(of),
897        Err(e) => {
898            set_last_error(e.to_string());
899            GbpBuffer::empty()
900        }
901    }
902}
903
904/// Accepts a GAP audio payload.
905///
906/// `codec` must match the value from the `DeliveredPayload::codec` field
907/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
908///
909/// # Safety
910/// `pt_ptr` MUST be valid for `pt_len` bytes.
911#[unsafe(no_mangle)]
912pub unsafe extern "C" fn gap_client_accept(
913    ch: i32,
914    current_epoch: u64,
915    pt_ptr: *const u8,
916    pt_len: usize,
917    codec: u8,
918) -> *mut c_char {
919    clear_last_error();
920    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
921    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
922    let Some(c_arc) = gaps().get(ch) else {
923        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
924    };
925    let mut c = c_arc.lock().unwrap();
926    #[derive(Serialize)]
927    struct Out<'a> {
928        status: &'a str,
929        source: Option<u32>,
930        seq: Option<u32>,
931        bytes: Option<usize>,
932        reason: Option<String>,
933    }
934    let out = match c.accept(pt, current_epoch, codec) {
935        Ok(GapAccept::New(p)) => Out {
936            status: "new",
937            source: Some(p.media_source_id),
938            seq: Some(p.rtp_sequence),
939            bytes: Some(p.opus_frame.len()),
940            reason: None,
941        },
942        Ok(GapAccept::Late(p)) => Out {
943            status: "late",
944            source: Some(p.media_source_id),
945            seq: Some(p.rtp_sequence),
946            bytes: Some(p.opus_frame.len()),
947            reason: None,
948        },
949        Err(e) => Out {
950            status: "error",
951            source: None,
952            seq: None,
953            bytes: None,
954            reason: Some(e.to_string()),
955        },
956    };
957    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
958}
959
960// ============================================================================
961// GSP client API
962// ============================================================================
963
964/// Creates a stateful GSP client.
965#[unsafe(no_mangle)]
966pub extern "C" fn gsp_client_create() -> i32 {
967    gsps().insert(GspClient::new())
968}
969
970/// Destroys a GSP client.
971#[unsafe(no_mangle)]
972pub extern "C" fn gsp_client_destroy(h: i32) {
973    gsps().remove(h);
974}
975
976/// Clears the client state. Intended for use after an epoch change.
977#[unsafe(no_mangle)]
978pub extern "C" fn gsp_client_reset(h: i32) {
979    if let Some(c) = gsps().get(h) {
980        c.lock().unwrap().reset();
981    }
982}
983
984/// Sends a GSP signal.
985///
986/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
987/// 2 = FlatBuffers. Unknown values fall back to CBOR.
988#[unsafe(no_mangle)]
989pub extern "C" fn gsp_client_send(
990    ch: i32,
991    nh: i32,
992    mh: i32,
993    target: u32,
994    signal_type: u32,
995    role_claim: u32,
996    request_id: u32,
997    codec: u8,
998) -> GbpBuffer {
999    clear_last_error();
1000    let sig = match SignalType::try_from(signal_type) {
1001        Ok(s) => s,
1002        Err(_) => {
1003            set_last_error(format!("bad signal {signal_type}"));
1004            return GbpBuffer::empty();
1005        }
1006    };
1007    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1008    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1009    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1010        set_last_error("bad handle");
1011        return GbpBuffer::empty();
1012    };
1013    let mut c = c_arc.lock().unwrap();
1014    let mut n = n_arc.lock().unwrap();
1015    let mut m = m_arc.lock().unwrap();
1016    match c.send(&mut *n, &mut *m, target, sig, role_claim, request_id, codec) {
1017        Ok(of) => outbound_to_buffer(of),
1018        Err(e) => {
1019            set_last_error(e.to_string());
1020            GbpBuffer::empty()
1021        }
1022    }
1023}
1024
1025/// Sends a GSP signal with opcode-specific CBOR `args` bytes.
1026/// Use this for signals that require structured arguments (MUTE, UNMUTE,
1027/// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE).
1028///
1029/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
1030/// 2 = FlatBuffers. Unknown values fall back to CBOR.
1031///
1032/// # Safety
1033/// `args_ptr` MUST be valid for `args_len` bytes.
1034#[unsafe(no_mangle)]
1035pub unsafe extern "C" fn gsp_client_send_with_args(
1036    ch: i32,
1037    nh: i32,
1038    mh: i32,
1039    target: u32,
1040    signal_type: u32,
1041    role_claim: u32,
1042    request_id: u32,
1043    args_ptr: *const u8,
1044    args_len: usize,
1045    codec: u8,
1046) -> GbpBuffer {
1047    clear_last_error();
1048    let args: &[u8] = if args_len == 0 || args_ptr.is_null() {
1049        &[]
1050    } else {
1051        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }
1052    };
1053    let sig = match SignalType::try_from(signal_type) {
1054        Ok(s) => s,
1055        Err(_) => {
1056            set_last_error(format!("bad signal {signal_type}"));
1057            return GbpBuffer::empty();
1058        }
1059    };
1060    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1061    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1062    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1063        set_last_error("bad handle");
1064        return GbpBuffer::empty();
1065    };
1066    let mut c = c_arc.lock().unwrap();
1067    let mut n = n_arc.lock().unwrap();
1068    let mut m = m_arc.lock().unwrap();
1069    match c.send_with_args(&mut *n, &mut *m, target, sig, role_claim, request_id, args, codec) {
1070        Ok(of) => outbound_to_buffer(of),
1071        Err(e) => {
1072            set_last_error(e.to_string());
1073            GbpBuffer::empty()
1074        }
1075    }
1076}
1077
1078/// Accepts a GSP signal payload.
1079///
1080/// `current_epoch` is the receiver node's current epoch — the client uses
1081/// it to auto-reset its dedup state when the epoch advances.
1082/// `codec` must match the value from the `DeliveredPayload::codec` field
1083/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
1084///
1085/// # Safety
1086/// `pt_ptr` MUST be valid for `pt_len` bytes.
1087#[unsafe(no_mangle)]
1088pub unsafe extern "C" fn gsp_client_accept(
1089    ch: i32,
1090    current_epoch: u64,
1091    pt_ptr: *const u8,
1092    pt_len: usize,
1093    codec: u8,
1094) -> *mut c_char {
1095    clear_last_error();
1096    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
1097    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1098    let Some(c_arc) = gsps().get(ch) else {
1099        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
1100    };
1101    let mut c = c_arc.lock().unwrap();
1102    #[derive(Serialize)]
1103    struct Out<'a> {
1104        status: &'a str,
1105        signal: Option<&'a str>,
1106        signal_code: Option<u32>,
1107        sender: Option<u32>,
1108        role_claim: Option<u32>,
1109        request_id: Option<u32>,
1110        reason: Option<String>,
1111    }
1112    let out = match c.accept(pt, current_epoch, codec) {
1113        Ok(GspAccept {
1114            signal,
1115            sender_id,
1116            role_claim,
1117            request_id,
1118        }) => Out {
1119            status: "new",
1120            signal: Some(signal.name()),
1121            signal_code: Some(signal as u32),
1122            sender: Some(sender_id),
1123            role_claim: Some(role_claim),
1124            request_id: Some(request_id),
1125            reason: None,
1126        },
1127        Err(gbp_stack::GspError::DuplicateRequest(rid)) => Out {
1128            status: "duplicate",
1129            signal: None,
1130            signal_code: None,
1131            sender: None,
1132            role_claim: None,
1133            request_id: Some(rid),
1134            reason: None,
1135        },
1136        Err(e) => Out {
1137            status: "error",
1138            signal: None,
1139            signal_code: None,
1140            sender: None,
1141            role_claim: None,
1142            request_id: None,
1143            reason: Some(e.to_string()),
1144        },
1145    };
1146    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1147}
1148
1149// ============================================================================
1150// Codec helpers (used for tests that need malformed frames)
1151// ============================================================================
1152
1153/// CBOR-encodes a [`gbp::GbpFrame`] with an arbitrary version byte.
1154///
1155/// # Safety
1156/// Every pointer MUST be valid for the corresponding declared length.
1157#[unsafe(no_mangle)]
1158pub unsafe extern "C" fn gbp_frame_encode_v(
1159    version: u8,
1160    group_id_16: *const u8,
1161    epoch: u64,
1162    transition_id: u32,
1163    stream_type: u32,
1164    stream_id: u32,
1165    flags: u16,
1166    sequence_no: u32,
1167    payload_ptr: *const u8,
1168    payload_len: usize,
1169) -> GbpBuffer {
1170    clear_last_error();
1171    let mut gid = [0u8; 16];
1172    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
1173    let st_u8 = StreamType::try_from(stream_type)
1174        .map(|s| s as u8)
1175        .unwrap_or(stream_type as u8);
1176    let payload: Vec<u8> = if payload_len == 0 || payload_ptr.is_null() {
1177        Vec::new()
1178    } else {
1179        unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) }.to_vec()
1180    };
1181    let frame = gbp_stack::gbp::GbpFrame {
1182        version,
1183        group_id: serde_bytes::ByteBuf::from(gid.to_vec()),
1184        epoch,
1185        transition_id,
1186        stream_type: st_u8,
1187        stream_id,
1188        flags,
1189        sequence_no,
1190        payload_format: 0u8,
1191        payload_size: payload.len() as u32,
1192        encrypted_payload: serde_bytes::ByteBuf::from(payload),
1193    };
1194    GbpBuffer::from_vec(frame.to_cbor())
1195}
1196
1197/// Returns a CBOR-encoded `ErrorObject` for the given code.
1198#[unsafe(no_mangle)]
1199pub extern "C" fn gbp_error_lookup(code: u16) -> GbpBuffer {
1200    use gbp_stack::core::errors::ErrorSpec;
1201    match ErrorSpec::lookup(code) {
1202        Some(spec) => GbpBuffer::from_vec(ErrorObject::from_spec(spec, spec.name).to_cbor()),
1203        None => {
1204            set_last_error(format!("unknown error code 0x{code:04X}"));
1205            GbpBuffer::empty()
1206        }
1207    }
1208}
1209
1210#[allow(dead_code)]
1211fn _link(_f: &GbpFrame, _l: StreamLabel) {}
1212
1213// ============================================================================
1214// Event JSON
1215// ============================================================================
1216
1217#[derive(Serialize)]
1218#[serde(tag = "kind", rename_all = "snake_case")]
1219enum EventDto<'a> {
1220    StateChanged {
1221        from: String,
1222        to: String,
1223    },
1224    PayloadReceived {
1225        stream_type: &'a str,
1226        stream_type_code: u32,
1227        stream_id: u32,
1228        sequence_no: u32,
1229        flags: u16,
1230        codec: u8,
1231        plaintext_b64: String,
1232    },
1233    Control {
1234        from: u32,
1235        opcode: &'a str,
1236        opcode_code: u16,
1237        transition_id: u32,
1238        request_id: u32,
1239        args_b64: String,
1240    },
1241    Error {
1242        code: u16,
1243        code_hex: String,
1244        class: u8,
1245        retryable: bool,
1246        fatal: bool,
1247        reason: String,
1248    },
1249    EpochAdvanced {
1250        epoch: u64,
1251        transition_id: u32,
1252    },
1253    CoordinatorElectionNeeded {},
1254    BecameCoordinator {},
1255    CoordinatorClaim {
1256        claimant: u32,
1257    },
1258}
1259
1260fn b64(b: &[u8]) -> String {
1261    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1262    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
1263    let mut i = 0;
1264    while i + 3 <= b.len() {
1265        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8) | (b[i + 2] as u32);
1266        out.push(A[(n >> 18) as usize & 0x3F] as char);
1267        out.push(A[(n >> 12) as usize & 0x3F] as char);
1268        out.push(A[(n >> 6) as usize & 0x3F] as char);
1269        out.push(A[n as usize & 0x3F] as char);
1270        i += 3;
1271    }
1272    let rem = b.len() - i;
1273    if rem == 1 {
1274        let n = (b[i] as u32) << 16;
1275        out.push(A[(n >> 18) as usize & 0x3F] as char);
1276        out.push(A[(n >> 12) as usize & 0x3F] as char);
1277        out.push('=');
1278        out.push('=');
1279    } else if rem == 2 {
1280        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8);
1281        out.push(A[(n >> 18) as usize & 0x3F] as char);
1282        out.push(A[(n >> 12) as usize & 0x3F] as char);
1283        out.push(A[(n >> 6) as usize & 0x3F] as char);
1284        out.push('=');
1285    }
1286    out
1287}
1288
1289fn dto<'a>(e: &'a Event) -> EventDto<'a> {
1290    match e {
1291        Event::StateChanged { from, to } => EventDto::StateChanged {
1292            from: from.to_string(),
1293            to: to.to_string(),
1294        },
1295        Event::PayloadReceived(DeliveredPayload {
1296            stream_type,
1297            stream_id,
1298            sequence_no,
1299            flags,
1300            plaintext,
1301            codec,
1302        }) => EventDto::PayloadReceived {
1303            stream_type: match stream_type {
1304                StreamType::Control => "control",
1305                StreamType::Audio => "audio",
1306                StreamType::Text => "text",
1307                StreamType::Signal => "signal",
1308            },
1309            stream_type_code: *stream_type as u32,
1310            stream_id: *stream_id,
1311            sequence_no: *sequence_no,
1312            flags: *flags,
1313            codec: codec.as_u8(),
1314            plaintext_b64: b64(plaintext),
1315        },
1316        Event::Control {
1317            from,
1318            opcode,
1319            transition_id,
1320            request_id,
1321            args,
1322        } => EventDto::Control {
1323            from: *from,
1324            opcode: opcode.name(),
1325            opcode_code: *opcode as u16,
1326            transition_id: *transition_id,
1327            request_id: *request_id,
1328            args_b64: b64(args),
1329        },
1330        Event::Error {
1331            code,
1332            class,
1333            retryable,
1334            fatal,
1335            reason,
1336        } => EventDto::Error {
1337            code: *code,
1338            code_hex: format!("0x{code:04X}"),
1339            class: *class as u8,
1340            retryable: *retryable,
1341            fatal: *fatal,
1342            reason: reason.clone(),
1343        },
1344        Event::EpochAdvanced {
1345            epoch,
1346            transition_id,
1347        } => EventDto::EpochAdvanced {
1348            epoch: *epoch,
1349            transition_id: *transition_id,
1350        },
1351        Event::CoordinatorElectionNeeded => EventDto::CoordinatorElectionNeeded {},
1352        Event::BecameCoordinator => EventDto::BecameCoordinator {},
1353        Event::CoordinatorClaim { claimant } => EventDto::CoordinatorClaim {
1354            claimant: *claimant,
1355        },
1356    }
1357}
1358
1359fn events_to_json(events: &[Event]) -> String {
1360    let dtos: Vec<EventDto> = events.iter().map(dto).collect();
1361    serde_json::to_string(&dtos).unwrap_or_else(|_| "[]".to_string())
1362}
1363
1364#[allow(dead_code)]
1365const _STATES: [NodeState; 7] = [
1366    NodeState::Idle,
1367    NodeState::Connecting,
1368    NodeState::EstablishingGroup,
1369    NodeState::Active,
1370    NodeState::Resyncing,
1371    NodeState::Failed,
1372    NodeState::Closed,
1373];
1374
1375// ============================================================================
1376// SFrame API  (`gbp_sframe_*`)
1377// ============================================================================
1378
1379/// Creates an SFrame session from an existing MLS context.
1380///
1381/// Derives `sframe_base_key = MLS.ExportSecret(label, epoch_be8, 32)` and
1382/// stores a [`SFrameDecryptor`] in the session registry.  Each SFrame
1383/// session corresponds to one MLS epoch; create a new session after every
1384/// commit.
1385///
1386/// Returns a positive session handle, or `0` on failure (check
1387/// [`gbp_last_error`]).
1388///
1389/// * `mls_handle` — handle from [`gbp_mls_create`].
1390/// * `suite` — `0` = AES-128-GCM, `1` = AES-256-GCM.
1391/// * `label_ptr` / `label_len` — UTF-8 export label (e.g. `"gbp/sframe v1"`).
1392///
1393/// # Safety
1394/// `label_ptr` MUST be valid UTF-8 for `label_len` bytes.
1395#[unsafe(no_mangle)]
1396pub unsafe extern "C" fn gbp_sframe_session_create(
1397    mls_handle: i32,
1398    suite: u8,
1399    label_ptr: *const u8,
1400    label_len: usize,
1401) -> i32 {
1402    clear_last_error();
1403    let suite = match CipherSuite::from_u8(suite) {
1404        Some(s) => s,
1405        None => {
1406            set_last_error(format!("unknown ciphersuite {suite}"));
1407            return 0;
1408        }
1409    };
1410    let label = unsafe {
1411        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1412            Ok(s) => s,
1413            Err(e) => {
1414                set_last_error(e);
1415                return 0;
1416            }
1417        }
1418    };
1419    let Some(mls_arc) = mls().get(mls_handle) else {
1420        set_last_error("invalid MLS handle");
1421        return 0;
1422    };
1423    let mls = mls_arc.lock().unwrap();
1424    match SFrameSession::from_mls(&mls, label, suite) {
1425        Ok(session) => sframe_sessions().insert(session.decryptor()),
1426        Err(e) => {
1427            set_last_error(e);
1428            0
1429        }
1430    }
1431}
1432
1433/// Frees an SFrame session created by [`gbp_sframe_session_create`].
1434#[unsafe(no_mangle)]
1435pub extern "C" fn gbp_sframe_session_free(handle: i32) {
1436    sframe_sessions().remove(handle);
1437}
1438
1439/// Creates an encryptor for the local sender (`leaf_index`) within an epoch.
1440///
1441/// The session handle MUST be the one returned by [`gbp_sframe_session_create`]
1442/// for the same epoch.  One encryptor per sender; do **not** share across
1443/// threads.
1444///
1445/// Returns a positive encryptor handle, or `0` on failure.
1446///
1447/// # Safety
1448/// `mls_handle` and `session_handle` must be valid.
1449#[unsafe(no_mangle)]
1450pub unsafe extern "C" fn gbp_sframe_encryptor_create(
1451    mls_handle: i32,
1452    session_handle: i32,
1453    leaf_index: u32,
1454    suite: u8,
1455    label_ptr: *const u8,
1456    label_len: usize,
1457) -> i32 {
1458    clear_last_error();
1459    let suite = match CipherSuite::from_u8(suite) {
1460        Some(s) => s,
1461        None => {
1462            set_last_error(format!("unknown ciphersuite {suite}"));
1463            return 0;
1464        }
1465    };
1466    let label = unsafe {
1467        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1468            Ok(s) => s,
1469            Err(e) => {
1470                set_last_error(e);
1471                return 0;
1472            }
1473        }
1474    };
1475    // Verify the session handle exists (keeps the API consistent).
1476    if sframe_sessions().get(session_handle).is_none() {
1477        set_last_error("invalid session handle");
1478        return 0;
1479    }
1480    let Some(mls_arc) = mls().get(mls_handle) else {
1481        set_last_error("invalid MLS handle");
1482        return 0;
1483    };
1484    let mls = mls_arc.lock().unwrap();
1485    match SFrameSession::from_mls(&mls, label, suite) {
1486        Ok(session) => sframe_encryptors().insert(session.encryptor(leaf_index)),
1487        Err(e) => {
1488            set_last_error(e);
1489            0
1490        }
1491    }
1492}
1493
1494/// Frees an encryptor created by [`gbp_sframe_encryptor_create`].
1495#[unsafe(no_mangle)]
1496pub extern "C" fn gbp_sframe_encryptor_free(handle: i32) {
1497    sframe_encryptors().remove(handle);
1498}
1499
1500/// Encrypts one audio frame.
1501///
1502/// Returns a [`GbpBuffer`] containing `sframe_header ‖ ciphertext ‖ tag`.
1503/// The caller MUST free it with [`gbp_buffer_free`].
1504///
1505/// On error returns an empty buffer and sets [`gbp_last_error`].
1506///
1507/// * `aad_ptr` / `aad_len` — additional authenticated data (e.g. RTP header);
1508///   pass a null pointer and `0` if none.
1509///
1510/// # Safety
1511/// All pointer/length pairs MUST be valid for their respective lengths.
1512#[unsafe(no_mangle)]
1513pub unsafe extern "C" fn gbp_sframe_encrypt(
1514    enc_handle: i32,
1515    plaintext_ptr: *const u8,
1516    plaintext_len: usize,
1517    aad_ptr: *const u8,
1518    aad_len: usize,
1519) -> GbpBuffer {
1520    clear_last_error();
1521    let Some(enc_arc) = sframe_encryptors().get(enc_handle) else {
1522        set_last_error("invalid encryptor handle");
1523        return GbpBuffer::empty();
1524    };
1525    let plaintext = unsafe { std::slice::from_raw_parts(plaintext_ptr, plaintext_len) };
1526    let aad = if aad_ptr.is_null() || aad_len == 0 {
1527        &[][..]
1528    } else {
1529        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1530    };
1531    let mut enc = enc_arc.lock().unwrap();
1532    match enc.encrypt(plaintext, aad) {
1533        Ok(payload) => GbpBuffer::from_vec(payload),
1534        Err(e) => {
1535            set_last_error(e);
1536            GbpBuffer::empty()
1537        }
1538    }
1539}
1540
1541/// Decrypts one SFrame payload.
1542///
1543/// Returns a [`GbpBuffer`] containing the plaintext Opus frame.
1544/// The caller MUST free it with [`gbp_buffer_free`].
1545///
1546/// On success, `*sender_leaf_out` is set to the sender's leaf index.
1547/// On error returns an empty buffer and sets [`gbp_last_error`].
1548///
1549/// # Safety
1550/// All pointer/length pairs MUST be valid for their respective lengths.
1551/// `sender_leaf_out` MUST be a valid non-null pointer to a `u32`.
1552#[unsafe(no_mangle)]
1553pub unsafe extern "C" fn gbp_sframe_decrypt(
1554    session_handle: i32,
1555    payload_ptr: *const u8,
1556    payload_len: usize,
1557    aad_ptr: *const u8,
1558    aad_len: usize,
1559    sender_leaf_out: *mut u32,
1560) -> GbpBuffer {
1561    clear_last_error();
1562    let Some(session_arc) = sframe_sessions().get(session_handle) else {
1563        set_last_error("invalid session handle");
1564        return GbpBuffer::empty();
1565    };
1566    let payload = unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) };
1567    let aad = if aad_ptr.is_null() || aad_len == 0 {
1568        &[][..]
1569    } else {
1570        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1571    };
1572    let mut dec = session_arc.lock().unwrap();
1573    match dec.decrypt(payload, aad) {
1574        Ok((plaintext, leaf)) => {
1575            if !sender_leaf_out.is_null() {
1576                unsafe {
1577                    *sender_leaf_out = leaf;
1578                }
1579            }
1580            GbpBuffer::from_vec(plaintext)
1581        }
1582        Err(e) => {
1583            set_last_error(e);
1584            GbpBuffer::empty()
1585        }
1586    }
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591    use super::b64;
1592
1593    #[test]
1594    fn b64_empty() {
1595        assert_eq!(b64(b""), "");
1596    }
1597
1598    #[test]
1599    fn b64_single_byte() {
1600        // 0xFF = "255" → base64: b"255" → "//8="
1601        // 0xFF >> 2 = 63 = '/', (0xFF & 0x03) << 4 = 60 = '8', pad = "=="
1602        // "255" in base64: first char = table[(255 >> 2)] = table[63] = '/'
1603        // second char = table[((255 & 3) << 4) | 0] = table[60] = '8'
1604        // pad = "==" → "//8="
1605        // Actually let me just check with known vectors.
1606        let s = b64(b"f");
1607        // "f" = 0x66 = 102. chars: table[102>>2=25]='Z', table[((102&3)<<4)=32]='g', pad="==" → "Zg=="
1608        assert_eq!(s, "Zg==");
1609    }
1610
1611    #[test]
1612    fn b64_two_bytes() {
1613        let s = b64(b"fo");
1614        // "fo" → 0x66, 0x6F. n = (0x66<<16)|(0x6F<<8)|0 = 0x666F00
1615        // c1 = table[n>>18] = table[0x666F00>>18=1] = 'B' ... let me just use a known-vector.
1616        // Actually "fo" in base64 is "Zm8="  (0x66='Z', combined='m8', pad='=')
1617        assert_eq!(s, "Zm8=");
1618    }
1619
1620    #[test]
1621    fn b64_three_bytes() {
1622        let s = b64(b"foo");
1623        // "foo" in standard base64 is "Zm9v"
1624        assert_eq!(s, "Zm9v");
1625    }
1626
1627    #[test]
1628    fn b64_known_vectors() {
1629        // Independently verified against RFC 4648 test vectors and Python base64.
1630        assert_eq!(b64(b""), "");
1631        assert_eq!(b64(b"f"), "Zg==");
1632        assert_eq!(b64(b"fo"), "Zm8=");
1633        assert_eq!(b64(b"foo"), "Zm9v");
1634        assert_eq!(b64(b"foob"), "Zm9vYg==");
1635        assert_eq!(b64(b"fooba"), "Zm9vYmE=");
1636        assert_eq!(b64(b"foobar"), "Zm9vYmFy");
1637    }
1638
1639    #[test]
1640    fn b64_padding_roundtrip() {
1641        // Verify padding is correct by checking every single-byte input 0..255
1642        for b in 0u8..=255 {
1643            let input = [b];
1644            let enc = b64(&input);
1645            // Every 1-byte base64 string is exactly 4 chars.
1646            assert_eq!(enc.len(), 4, "len mismatch for 0x{b:02X}: {enc}");
1647            // The last two chars must be padding.
1648            assert!(enc.ends_with("=="), "missing padding for 0x{b:02X}: {enc}");
1649        }
1650    }
1651}