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/// Serialises the full MLS state of handle `h` into an opaque blob that
517/// [`gbp_mls_restore_state`] can reconstruct. Lets consumers persist a context
518/// (disk / IndexedDB) so a group survives a restart. The blob contains
519/// **private key material** — store it encrypted at rest. The caller MUST free
520/// the returned buffer with [`gbp_buffer_free`]. Returns an empty buffer on
521/// failure (see [`gbp_last_error`]).
522#[unsafe(no_mangle)]
523pub extern "C" fn gbp_mls_export_state(h: i32) -> GbpBuffer {
524    clear_last_error();
525    let Some(ctx_arc) = mls().get(h) else {
526        set_last_error("invalid MLS handle");
527        return GbpBuffer::empty();
528    };
529    let ctx = ctx_arc.lock().unwrap();
530    match ctx.export_state() {
531        Ok(bytes) => GbpBuffer::from_vec(bytes),
532        Err(e) => {
533            set_last_error(e);
534            GbpBuffer::empty()
535        }
536    }
537}
538
539/// Reconstructs an MLS context from a blob produced by [`gbp_mls_export_state`].
540/// Returns the new handle, or `0` on failure. The restored context is at the
541/// same epoch / group state and can immediately send and receive again.
542///
543/// # Safety
544/// `ptr` MUST be valid for `len` bytes.
545#[unsafe(no_mangle)]
546pub unsafe extern "C" fn gbp_mls_restore_state(ptr: *const u8, len: usize) -> i32 {
547    clear_last_error();
548    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
549    match MlsContext::restore_state(bytes) {
550        Ok(ctx) => mls().insert(ctx),
551        Err(e) => {
552            set_last_error(e);
553            0
554        }
555    }
556}
557
558// ============================================================================
559// GBP node API (the IP-like base layer)
560// ============================================================================
561
562/// Creates a new GBP node and returns its handle.
563///
564/// # Safety
565/// `group_id_16` MUST be valid for 16 bytes.
566#[unsafe(no_mangle)]
567pub unsafe extern "C" fn gbp_node_create(member_id: u32, group_id_16: *const u8) -> i32 {
568    clear_last_error();
569    let mut gid = [0u8; 16];
570    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
571    nodes().insert(GroupNode::new(member_id, gid))
572}
573
574/// Destroys a GBP node.
575#[unsafe(no_mangle)]
576pub extern "C" fn gbp_node_destroy(h: i32) {
577    nodes().remove(h);
578}
579
580/// Drives the node to `ACTIVE` as a creator.
581#[unsafe(no_mangle)]
582pub extern "C" fn gbp_node_bootstrap_creator(h: i32, epoch: u64) -> bool {
583    let Some(n_arc) = nodes().get(h) else {
584        return false;
585    };
586    n_arc.lock().unwrap().bootstrap_as_creator(epoch);
587    true
588}
589
590/// Drives the node to `ACTIVE` as a joiner. `expected_first_tid` lets the
591/// joiner pre-arm pending transition state so that the first
592/// `EXECUTE_TRANSITION` after Welcome is accepted; pass `0` if the joiner
593/// recovered out-of-band and is already current.
594#[unsafe(no_mangle)]
595pub extern "C" fn gbp_node_bootstrap_joiner(h: i32, epoch: u64, expected_first_tid: u32) -> bool {
596    let Some(n_arc) = nodes().get(h) else {
597        return false;
598    };
599    n_arc
600        .lock()
601        .unwrap()
602        .bootstrap_as_joiner(epoch, expected_first_tid);
603    true
604}
605
606/// Returns the current `NodeState` encoded as `u32`.
607#[unsafe(no_mangle)]
608pub extern "C" fn gbp_node_state(h: i32) -> u32 {
609    nodes()
610        .get(h)
611        .map(|n| n.lock().unwrap().state as u32)
612        .unwrap_or(u32::MAX)
613}
614
615/// Returns the node's current epoch.
616#[unsafe(no_mangle)]
617pub extern "C" fn gbp_node_epoch(h: i32) -> u64 {
618    nodes()
619        .get(h)
620        .map(|n| n.lock().unwrap().current_epoch)
621        .unwrap_or(0)
622}
623
624/// Returns the node's last applied `transition_id`.
625#[unsafe(no_mangle)]
626pub extern "C" fn gbp_node_last_transition_id(h: i32) -> u32 {
627    nodes()
628        .get(h)
629        .map(|n| n.lock().unwrap().last_transition_id)
630        .unwrap_or(0)
631}
632
633/// Forcibly sets the node's `current_epoch` (intended for tests of late
634/// peers and `EPOCH_MISMATCH` recovery).
635#[unsafe(no_mangle)]
636pub extern "C" fn gbp_node_set_epoch(h: i32, epoch: u64) -> bool {
637    let Some(n_arc) = nodes().get(h) else {
638        return false;
639    };
640    n_arc.lock().unwrap().current_epoch = epoch;
641    true
642}
643
644/// Applies an epoch transition locally.
645#[unsafe(no_mangle)]
646pub extern "C" fn gbp_node_apply_transition(h: i32, tid: u32) -> bool {
647    let Some(n_arc) = nodes().get(h) else {
648        return false;
649    };
650    n_arc.lock().unwrap().apply_transition(tid);
651    true
652}
653
654/// Sends a control plane message on Stream 0.
655///
656/// The returned buffer layout is `[u32-LE target | wire]`.
657///
658/// # Safety
659/// `args_ptr` MUST be valid for `args_len` bytes.
660#[unsafe(no_mangle)]
661pub unsafe extern "C" fn gbp_node_send_control(
662    nh: i32,
663    mh: i32,
664    target: u32,
665    opcode: u16,
666    transition_id: u32,
667    request_id: u32,
668    args_ptr: *const u8,
669    args_len: usize,
670) -> GbpBuffer {
671    clear_last_error();
672    let op = match ControlOpcode::try_from(opcode) {
673        Ok(o) => o,
674        Err(_) => {
675            set_last_error(format!("bad opcode 0x{opcode:04X}"));
676            return GbpBuffer::empty();
677        }
678    };
679    let args = if args_len == 0 {
680        Vec::new()
681    } else {
682        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }.to_vec()
683    };
684    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
685    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
686        set_last_error("bad node/mls handle");
687        return GbpBuffer::empty();
688    };
689    let mut n = n_arc.lock().unwrap();
690    let mut m = m_arc.lock().unwrap();
691    match n.send_control(&mut *m, target, op, transition_id, request_id, args) {
692        Ok(of) => outbound_to_buffer(of),
693        Err(e) => {
694            set_last_error(e.to_string());
695            GbpBuffer::empty()
696        }
697    }
698}
699
700/// Feeds wire bytes to the node. Returns a JSON-encoded array of events.
701///
702/// # Safety
703/// `wire_ptr` MUST be valid for `wire_len` bytes.
704#[unsafe(no_mangle)]
705pub unsafe extern "C" fn gbp_node_on_wire(
706    nh: i32,
707    mh: i32,
708    wire_ptr: *const u8,
709    wire_len: usize,
710) -> *mut c_char {
711    clear_last_error();
712    let wire = unsafe { std::slice::from_raw_parts(wire_ptr, wire_len) };
713    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
714    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
715        set_last_error("bad node/mls handle");
716        return alloc_cstring("[]");
717    };
718    let mut n = n_arc.lock().unwrap();
719    let mut m = m_arc.lock().unwrap();
720    let events = match n.on_wire(&mut *m, wire) {
721        Ok(e) => e,
722        Err(e) => {
723            set_last_error(e.to_string());
724            return alloc_cstring("[]");
725        }
726    };
727    alloc_cstring(&events_to_json(&events))
728}
729
730/// Drains the queued events (without consuming any wire bytes).
731#[unsafe(no_mangle)]
732pub extern "C" fn gbp_node_drain_events(nh: i32) -> *mut c_char {
733    let Some(n_arc) = nodes().get(nh) else {
734        return alloc_cstring("[]");
735    };
736    alloc_cstring(&events_to_json(&n_arc.lock().unwrap().drain_events()))
737}
738
739fn outbound_to_buffer(of: OutboundFrame) -> GbpBuffer {
740    let mut out = Vec::with_capacity(4 + of.wire.len());
741    out.extend_from_slice(&of.to.to_le_bytes());
742    out.extend_from_slice(&of.wire);
743    GbpBuffer::from_vec(out)
744}
745
746// ============================================================================
747// GTP client API
748// ============================================================================
749
750/// Creates a stateful GTP client (idempotency tracking).
751#[unsafe(no_mangle)]
752pub extern "C" fn gtp_client_create() -> i32 {
753    gtps().insert(GtpClient::new())
754}
755
756/// Destroys a GTP client.
757#[unsafe(no_mangle)]
758pub extern "C" fn gtp_client_destroy(h: i32) {
759    gtps().remove(h);
760}
761
762/// Clears the client state. Intended for use after an epoch change.
763#[unsafe(no_mangle)]
764pub extern "C" fn gtp_client_reset(h: i32) {
765    if let Some(c) = gtps().get(h) {
766        c.lock().unwrap().reset();
767    }
768}
769
770/// Sends a text message via GTP.
771///
772/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
773/// 2 = FlatBuffers. Unknown values fall back to CBOR.
774///
775/// # Safety
776/// `text_ptr` MUST be valid UTF-8 for `text_len` bytes.
777#[unsafe(no_mangle)]
778pub unsafe extern "C" fn gtp_client_send(
779    ch: i32,
780    nh: i32,
781    mh: i32,
782    target: u32,
783    message_id: u64,
784    text_ptr: *const u8,
785    text_len: usize,
786    codec: u8,
787) -> GbpBuffer {
788    clear_last_error();
789    let text = unsafe { std::slice::from_raw_parts(text_ptr, text_len) };
790    let text = match std::str::from_utf8(text) {
791        Ok(s) => s,
792        Err(e) => {
793            set_last_error(format!("utf8: {e}"));
794            return GbpBuffer::empty();
795        }
796    };
797    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
798    let (c_arc, n_arc, m_arc) = (gtps().get(ch), nodes().get(nh), mls().get(mh));
799    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
800        set_last_error("bad handle");
801        return GbpBuffer::empty();
802    };
803    let mut c = c_arc.lock().unwrap();
804    let mut n = n_arc.lock().unwrap();
805    let mut m = m_arc.lock().unwrap();
806    match c.send(&mut *n, &mut *m, target, message_id, text, codec) {
807        Ok(of) => outbound_to_buffer(of),
808        Err(e) => {
809            set_last_error(e.to_string());
810            GbpBuffer::empty()
811        }
812    }
813}
814
815/// Accepts a plaintext payload that the GBP layer surfaced via a
816/// `payload_received` event. Returns a JSON object of the form
817/// `{"status":"new|duplicate|error", ...}`.
818///
819/// `current_epoch` is the receiver node's current epoch — the client uses
820/// it to auto-reset its idempotency state when the epoch advances.
821/// `codec` must match the value from the `DeliveredPayload::codec` field
822/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
823///
824/// # Safety
825/// `pt_ptr` MUST be valid for `pt_len` bytes.
826#[unsafe(no_mangle)]
827pub unsafe extern "C" fn gtp_client_accept(
828    ch: i32,
829    current_epoch: u64,
830    pt_ptr: *const u8,
831    pt_len: usize,
832    codec: u8,
833) -> *mut c_char {
834    clear_last_error();
835    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
836    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
837    let Some(c_arc) = gtps().get(ch) else {
838        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
839    };
840    let mut c = c_arc.lock().unwrap();
841    #[derive(Serialize)]
842    struct Out<'a> {
843        status: &'a str,
844        sender: Option<u32>,
845        message_id: Option<u64>,
846        text: Option<String>,
847        reason: Option<String>,
848    }
849    let out = match c.accept(pt, current_epoch, codec) {
850        Ok(GtpAccept::New(m)) => Out {
851            status: "new",
852            sender: Some(m.sender_id),
853            message_id: Some(m.message_id),
854            text: Some(m.text().unwrap_or("<binary>").to_string()),
855            reason: None,
856        },
857        Ok(GtpAccept::Duplicate(m)) => Out {
858            status: "duplicate",
859            sender: Some(m.sender_id),
860            message_id: Some(m.message_id),
861            text: Some(m.text().unwrap_or("<binary>").to_string()),
862            reason: None,
863        },
864        Err(e) => Out {
865            status: "error",
866            sender: None,
867            message_id: None,
868            text: None,
869            reason: Some(e.to_string()),
870        },
871    };
872    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
873}
874
875// ============================================================================
876// GAP client API
877// ============================================================================
878
879/// Creates a stateful GAP client.
880#[unsafe(no_mangle)]
881pub extern "C" fn gap_client_create() -> i32 {
882    gaps().insert(GapClient::new())
883}
884
885/// Destroys a GAP client.
886#[unsafe(no_mangle)]
887pub extern "C" fn gap_client_destroy(h: i32) {
888    gaps().remove(h);
889}
890
891/// Clears the client state. Intended for use after an epoch change.
892#[unsafe(no_mangle)]
893pub extern "C" fn gap_client_reset(h: i32) {
894    if let Some(c) = gaps().get(h) {
895        c.lock().unwrap().reset();
896    }
897}
898
899/// Sends an Opus audio frame via GAP.
900///
901/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
902/// 2 = FlatBuffers. For audio use FlatBuffers (2) to minimize decode latency.
903///
904/// # Safety
905/// `opus_ptr` MUST be valid for `opus_len` bytes.
906#[unsafe(no_mangle)]
907pub unsafe extern "C" fn gap_client_send(
908    ch: i32,
909    nh: i32,
910    mh: i32,
911    target: u32,
912    media_source_id: u32,
913    rtp_timestamp: u64,
914    opus_ptr: *const u8,
915    opus_len: usize,
916    codec: u8,
917) -> GbpBuffer {
918    clear_last_error();
919    let opus = unsafe { std::slice::from_raw_parts(opus_ptr, opus_len) }.to_vec();
920    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
921    let (c_arc, n_arc, m_arc) = (gaps().get(ch), nodes().get(nh), mls().get(mh));
922    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
923        set_last_error("bad handle");
924        return GbpBuffer::empty();
925    };
926    let mut c = c_arc.lock().unwrap();
927    let mut n = n_arc.lock().unwrap();
928    let mut m = m_arc.lock().unwrap();
929    match c.send(
930        &mut *n,
931        &mut *m,
932        target,
933        media_source_id,
934        rtp_timestamp,
935        opus,
936        codec,
937    ) {
938        Ok(of) => outbound_to_buffer(of),
939        Err(e) => {
940            set_last_error(e.to_string());
941            GbpBuffer::empty()
942        }
943    }
944}
945
946/// Accepts a GAP audio payload.
947///
948/// `codec` must match the value from the `DeliveredPayload::codec` field
949/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
950///
951/// # Safety
952/// `pt_ptr` MUST be valid for `pt_len` bytes.
953#[unsafe(no_mangle)]
954pub unsafe extern "C" fn gap_client_accept(
955    ch: i32,
956    current_epoch: u64,
957    pt_ptr: *const u8,
958    pt_len: usize,
959    codec: u8,
960) -> *mut c_char {
961    clear_last_error();
962    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
963    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
964    let Some(c_arc) = gaps().get(ch) else {
965        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
966    };
967    let mut c = c_arc.lock().unwrap();
968    #[derive(Serialize)]
969    struct Out<'a> {
970        status: &'a str,
971        source: Option<u32>,
972        seq: Option<u32>,
973        bytes: Option<usize>,
974        reason: Option<String>,
975    }
976    let out = match c.accept(pt, current_epoch, codec) {
977        Ok(GapAccept::New(p)) => Out {
978            status: "new",
979            source: Some(p.media_source_id),
980            seq: Some(p.rtp_sequence),
981            bytes: Some(p.opus_frame.len()),
982            reason: None,
983        },
984        Ok(GapAccept::Late(p)) => Out {
985            status: "late",
986            source: Some(p.media_source_id),
987            seq: Some(p.rtp_sequence),
988            bytes: Some(p.opus_frame.len()),
989            reason: None,
990        },
991        Err(e) => Out {
992            status: "error",
993            source: None,
994            seq: None,
995            bytes: None,
996            reason: Some(e.to_string()),
997        },
998    };
999    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1000}
1001
1002// ============================================================================
1003// GSP client API
1004// ============================================================================
1005
1006/// Creates a stateful GSP client.
1007#[unsafe(no_mangle)]
1008pub extern "C" fn gsp_client_create() -> i32 {
1009    gsps().insert(GspClient::new())
1010}
1011
1012/// Destroys a GSP client.
1013#[unsafe(no_mangle)]
1014pub extern "C" fn gsp_client_destroy(h: i32) {
1015    gsps().remove(h);
1016}
1017
1018/// Clears the client state. Intended for use after an epoch change.
1019#[unsafe(no_mangle)]
1020pub extern "C" fn gsp_client_reset(h: i32) {
1021    if let Some(c) = gsps().get(h) {
1022        c.lock().unwrap().reset();
1023    }
1024}
1025
1026/// Sends a GSP signal.
1027///
1028/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
1029/// 2 = FlatBuffers. Unknown values fall back to CBOR.
1030#[unsafe(no_mangle)]
1031pub extern "C" fn gsp_client_send(
1032    ch: i32,
1033    nh: i32,
1034    mh: i32,
1035    target: u32,
1036    signal_type: u32,
1037    role_claim: u32,
1038    request_id: u32,
1039    codec: u8,
1040) -> GbpBuffer {
1041    clear_last_error();
1042    let sig = match SignalType::try_from(signal_type) {
1043        Ok(s) => s,
1044        Err(_) => {
1045            set_last_error(format!("bad signal {signal_type}"));
1046            return GbpBuffer::empty();
1047        }
1048    };
1049    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1050    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1051    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1052        set_last_error("bad handle");
1053        return GbpBuffer::empty();
1054    };
1055    let mut c = c_arc.lock().unwrap();
1056    let mut n = n_arc.lock().unwrap();
1057    let mut m = m_arc.lock().unwrap();
1058    match c.send(&mut *n, &mut *m, target, sig, role_claim, request_id, codec) {
1059        Ok(of) => outbound_to_buffer(of),
1060        Err(e) => {
1061            set_last_error(e.to_string());
1062            GbpBuffer::empty()
1063        }
1064    }
1065}
1066
1067/// Sends a GSP signal with opcode-specific CBOR `args` bytes.
1068/// Use this for signals that require structured arguments (MUTE, UNMUTE,
1069/// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE).
1070///
1071/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
1072/// 2 = FlatBuffers. Unknown values fall back to CBOR.
1073///
1074/// # Safety
1075/// `args_ptr` MUST be valid for `args_len` bytes.
1076#[unsafe(no_mangle)]
1077pub unsafe extern "C" fn gsp_client_send_with_args(
1078    ch: i32,
1079    nh: i32,
1080    mh: i32,
1081    target: u32,
1082    signal_type: u32,
1083    role_claim: u32,
1084    request_id: u32,
1085    args_ptr: *const u8,
1086    args_len: usize,
1087    codec: u8,
1088) -> GbpBuffer {
1089    clear_last_error();
1090    let args: &[u8] = if args_len == 0 || args_ptr.is_null() {
1091        &[]
1092    } else {
1093        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }
1094    };
1095    let sig = match SignalType::try_from(signal_type) {
1096        Ok(s) => s,
1097        Err(_) => {
1098            set_last_error(format!("bad signal {signal_type}"));
1099            return GbpBuffer::empty();
1100        }
1101    };
1102    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1103    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1104    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1105        set_last_error("bad handle");
1106        return GbpBuffer::empty();
1107    };
1108    let mut c = c_arc.lock().unwrap();
1109    let mut n = n_arc.lock().unwrap();
1110    let mut m = m_arc.lock().unwrap();
1111    match c.send_with_args(&mut *n, &mut *m, target, sig, role_claim, request_id, args, codec) {
1112        Ok(of) => outbound_to_buffer(of),
1113        Err(e) => {
1114            set_last_error(e.to_string());
1115            GbpBuffer::empty()
1116        }
1117    }
1118}
1119
1120/// Accepts a GSP signal payload.
1121///
1122/// `current_epoch` is the receiver node's current epoch — the client uses
1123/// it to auto-reset its dedup state when the epoch advances.
1124/// `codec` must match the value from the `DeliveredPayload::codec` field
1125/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
1126///
1127/// # Safety
1128/// `pt_ptr` MUST be valid for `pt_len` bytes.
1129#[unsafe(no_mangle)]
1130pub unsafe extern "C" fn gsp_client_accept(
1131    ch: i32,
1132    current_epoch: u64,
1133    pt_ptr: *const u8,
1134    pt_len: usize,
1135    codec: u8,
1136) -> *mut c_char {
1137    clear_last_error();
1138    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
1139    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1140    let Some(c_arc) = gsps().get(ch) else {
1141        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
1142    };
1143    let mut c = c_arc.lock().unwrap();
1144    #[derive(Serialize)]
1145    struct Out<'a> {
1146        status: &'a str,
1147        signal: Option<&'a str>,
1148        signal_code: Option<u32>,
1149        sender: Option<u32>,
1150        role_claim: Option<u32>,
1151        request_id: Option<u32>,
1152        reason: Option<String>,
1153    }
1154    let out = match c.accept(pt, current_epoch, codec) {
1155        Ok(GspAccept {
1156            signal,
1157            sender_id,
1158            role_claim,
1159            request_id,
1160        }) => Out {
1161            status: "new",
1162            signal: Some(signal.name()),
1163            signal_code: Some(signal as u32),
1164            sender: Some(sender_id),
1165            role_claim: Some(role_claim),
1166            request_id: Some(request_id),
1167            reason: None,
1168        },
1169        Err(gbp_stack::GspError::DuplicateRequest(rid)) => Out {
1170            status: "duplicate",
1171            signal: None,
1172            signal_code: None,
1173            sender: None,
1174            role_claim: None,
1175            request_id: Some(rid),
1176            reason: None,
1177        },
1178        Err(e) => Out {
1179            status: "error",
1180            signal: None,
1181            signal_code: None,
1182            sender: None,
1183            role_claim: None,
1184            request_id: None,
1185            reason: Some(e.to_string()),
1186        },
1187    };
1188    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1189}
1190
1191// ============================================================================
1192// Codec helpers (used for tests that need malformed frames)
1193// ============================================================================
1194
1195/// CBOR-encodes a [`gbp::GbpFrame`] with an arbitrary version byte.
1196///
1197/// # Safety
1198/// Every pointer MUST be valid for the corresponding declared length.
1199#[unsafe(no_mangle)]
1200pub unsafe extern "C" fn gbp_frame_encode_v(
1201    version: u8,
1202    group_id_16: *const u8,
1203    epoch: u64,
1204    transition_id: u32,
1205    stream_type: u32,
1206    stream_id: u32,
1207    flags: u16,
1208    sequence_no: u32,
1209    payload_ptr: *const u8,
1210    payload_len: usize,
1211) -> GbpBuffer {
1212    clear_last_error();
1213    let mut gid = [0u8; 16];
1214    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
1215    let st_u8 = StreamType::try_from(stream_type)
1216        .map(|s| s as u8)
1217        .unwrap_or(stream_type as u8);
1218    let payload: Vec<u8> = if payload_len == 0 || payload_ptr.is_null() {
1219        Vec::new()
1220    } else {
1221        unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) }.to_vec()
1222    };
1223    let frame = gbp_stack::gbp::GbpFrame {
1224        version,
1225        group_id: serde_bytes::ByteBuf::from(gid.to_vec()),
1226        epoch,
1227        transition_id,
1228        stream_type: st_u8,
1229        stream_id,
1230        flags,
1231        sequence_no,
1232        payload_format: 0u8,
1233        payload_size: payload.len() as u32,
1234        encrypted_payload: serde_bytes::ByteBuf::from(payload),
1235    };
1236    GbpBuffer::from_vec(frame.to_cbor())
1237}
1238
1239/// Returns a CBOR-encoded `ErrorObject` for the given code.
1240#[unsafe(no_mangle)]
1241pub extern "C" fn gbp_error_lookup(code: u16) -> GbpBuffer {
1242    use gbp_stack::core::errors::ErrorSpec;
1243    match ErrorSpec::lookup(code) {
1244        Some(spec) => GbpBuffer::from_vec(ErrorObject::from_spec(spec, spec.name).to_cbor()),
1245        None => {
1246            set_last_error(format!("unknown error code 0x{code:04X}"));
1247            GbpBuffer::empty()
1248        }
1249    }
1250}
1251
1252#[allow(dead_code)]
1253fn _link(_f: &GbpFrame, _l: StreamLabel) {}
1254
1255// ============================================================================
1256// Event JSON
1257// ============================================================================
1258
1259#[derive(Serialize)]
1260#[serde(tag = "kind", rename_all = "snake_case")]
1261enum EventDto<'a> {
1262    StateChanged {
1263        from: String,
1264        to: String,
1265    },
1266    PayloadReceived {
1267        stream_type: &'a str,
1268        stream_type_code: u32,
1269        stream_id: u32,
1270        sequence_no: u32,
1271        flags: u16,
1272        codec: u8,
1273        plaintext_b64: String,
1274    },
1275    Control {
1276        from: u32,
1277        opcode: &'a str,
1278        opcode_code: u16,
1279        transition_id: u32,
1280        request_id: u32,
1281        args_b64: String,
1282    },
1283    Error {
1284        code: u16,
1285        code_hex: String,
1286        class: u8,
1287        retryable: bool,
1288        fatal: bool,
1289        reason: String,
1290    },
1291    EpochAdvanced {
1292        epoch: u64,
1293        transition_id: u32,
1294    },
1295    CoordinatorElectionNeeded {},
1296    BecameCoordinator {},
1297    CoordinatorClaim {
1298        claimant: u32,
1299    },
1300}
1301
1302fn b64(b: &[u8]) -> String {
1303    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1304    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
1305    let mut i = 0;
1306    while i + 3 <= b.len() {
1307        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8) | (b[i + 2] as u32);
1308        out.push(A[(n >> 18) as usize & 0x3F] as char);
1309        out.push(A[(n >> 12) as usize & 0x3F] as char);
1310        out.push(A[(n >> 6) as usize & 0x3F] as char);
1311        out.push(A[n as usize & 0x3F] as char);
1312        i += 3;
1313    }
1314    let rem = b.len() - i;
1315    if rem == 1 {
1316        let n = (b[i] as u32) << 16;
1317        out.push(A[(n >> 18) as usize & 0x3F] as char);
1318        out.push(A[(n >> 12) as usize & 0x3F] as char);
1319        out.push('=');
1320        out.push('=');
1321    } else if rem == 2 {
1322        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8);
1323        out.push(A[(n >> 18) as usize & 0x3F] as char);
1324        out.push(A[(n >> 12) as usize & 0x3F] as char);
1325        out.push(A[(n >> 6) as usize & 0x3F] as char);
1326        out.push('=');
1327    }
1328    out
1329}
1330
1331fn dto<'a>(e: &'a Event) -> EventDto<'a> {
1332    match e {
1333        Event::StateChanged { from, to } => EventDto::StateChanged {
1334            from: from.to_string(),
1335            to: to.to_string(),
1336        },
1337        Event::PayloadReceived(DeliveredPayload {
1338            stream_type,
1339            stream_id,
1340            sequence_no,
1341            flags,
1342            plaintext,
1343            codec,
1344        }) => EventDto::PayloadReceived {
1345            stream_type: match stream_type {
1346                StreamType::Control => "control",
1347                StreamType::Audio => "audio",
1348                StreamType::Text => "text",
1349                StreamType::Signal => "signal",
1350            },
1351            stream_type_code: *stream_type as u32,
1352            stream_id: *stream_id,
1353            sequence_no: *sequence_no,
1354            flags: *flags,
1355            codec: codec.as_u8(),
1356            plaintext_b64: b64(plaintext),
1357        },
1358        Event::Control {
1359            from,
1360            opcode,
1361            transition_id,
1362            request_id,
1363            args,
1364        } => EventDto::Control {
1365            from: *from,
1366            opcode: opcode.name(),
1367            opcode_code: *opcode as u16,
1368            transition_id: *transition_id,
1369            request_id: *request_id,
1370            args_b64: b64(args),
1371        },
1372        Event::Error {
1373            code,
1374            class,
1375            retryable,
1376            fatal,
1377            reason,
1378        } => EventDto::Error {
1379            code: *code,
1380            code_hex: format!("0x{code:04X}"),
1381            class: *class as u8,
1382            retryable: *retryable,
1383            fatal: *fatal,
1384            reason: reason.clone(),
1385        },
1386        Event::EpochAdvanced {
1387            epoch,
1388            transition_id,
1389        } => EventDto::EpochAdvanced {
1390            epoch: *epoch,
1391            transition_id: *transition_id,
1392        },
1393        Event::CoordinatorElectionNeeded => EventDto::CoordinatorElectionNeeded {},
1394        Event::BecameCoordinator => EventDto::BecameCoordinator {},
1395        Event::CoordinatorClaim { claimant } => EventDto::CoordinatorClaim {
1396            claimant: *claimant,
1397        },
1398    }
1399}
1400
1401fn events_to_json(events: &[Event]) -> String {
1402    let dtos: Vec<EventDto> = events.iter().map(dto).collect();
1403    serde_json::to_string(&dtos).unwrap_or_else(|_| "[]".to_string())
1404}
1405
1406#[allow(dead_code)]
1407const _STATES: [NodeState; 7] = [
1408    NodeState::Idle,
1409    NodeState::Connecting,
1410    NodeState::EstablishingGroup,
1411    NodeState::Active,
1412    NodeState::Resyncing,
1413    NodeState::Failed,
1414    NodeState::Closed,
1415];
1416
1417// ============================================================================
1418// SFrame API  (`gbp_sframe_*`)
1419// ============================================================================
1420
1421/// Creates an SFrame session from an existing MLS context.
1422///
1423/// Derives `sframe_base_key = MLS.ExportSecret(label, epoch_be8, 32)` and
1424/// stores a [`SFrameDecryptor`] in the session registry.  Each SFrame
1425/// session corresponds to one MLS epoch; create a new session after every
1426/// commit.
1427///
1428/// Returns a positive session handle, or `0` on failure (check
1429/// [`gbp_last_error`]).
1430///
1431/// * `mls_handle` — handle from [`gbp_mls_create`].
1432/// * `suite` — `0` = AES-128-GCM, `1` = AES-256-GCM.
1433/// * `label_ptr` / `label_len` — UTF-8 export label (e.g. `"gbp/sframe v1"`).
1434///
1435/// # Safety
1436/// `label_ptr` MUST be valid UTF-8 for `label_len` bytes.
1437#[unsafe(no_mangle)]
1438pub unsafe extern "C" fn gbp_sframe_session_create(
1439    mls_handle: i32,
1440    suite: u8,
1441    label_ptr: *const u8,
1442    label_len: usize,
1443) -> i32 {
1444    clear_last_error();
1445    let suite = match CipherSuite::from_u8(suite) {
1446        Some(s) => s,
1447        None => {
1448            set_last_error(format!("unknown ciphersuite {suite}"));
1449            return 0;
1450        }
1451    };
1452    let label = unsafe {
1453        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1454            Ok(s) => s,
1455            Err(e) => {
1456                set_last_error(e);
1457                return 0;
1458            }
1459        }
1460    };
1461    let Some(mls_arc) = mls().get(mls_handle) else {
1462        set_last_error("invalid MLS handle");
1463        return 0;
1464    };
1465    let mls = mls_arc.lock().unwrap();
1466    match SFrameSession::from_mls(&mls, label, suite) {
1467        Ok(session) => sframe_sessions().insert(session.decryptor()),
1468        Err(e) => {
1469            set_last_error(e);
1470            0
1471        }
1472    }
1473}
1474
1475/// Frees an SFrame session created by [`gbp_sframe_session_create`].
1476#[unsafe(no_mangle)]
1477pub extern "C" fn gbp_sframe_session_free(handle: i32) {
1478    sframe_sessions().remove(handle);
1479}
1480
1481/// Creates an encryptor for the local sender (`leaf_index`) within an epoch.
1482///
1483/// The session handle MUST be the one returned by [`gbp_sframe_session_create`]
1484/// for the same epoch.  One encryptor per sender; do **not** share across
1485/// threads.
1486///
1487/// Returns a positive encryptor handle, or `0` on failure.
1488///
1489/// # Safety
1490/// `mls_handle` and `session_handle` must be valid.
1491#[unsafe(no_mangle)]
1492pub unsafe extern "C" fn gbp_sframe_encryptor_create(
1493    mls_handle: i32,
1494    session_handle: i32,
1495    leaf_index: u32,
1496    suite: u8,
1497    label_ptr: *const u8,
1498    label_len: usize,
1499) -> i32 {
1500    clear_last_error();
1501    let suite = match CipherSuite::from_u8(suite) {
1502        Some(s) => s,
1503        None => {
1504            set_last_error(format!("unknown ciphersuite {suite}"));
1505            return 0;
1506        }
1507    };
1508    let label = unsafe {
1509        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1510            Ok(s) => s,
1511            Err(e) => {
1512                set_last_error(e);
1513                return 0;
1514            }
1515        }
1516    };
1517    // Verify the session handle exists (keeps the API consistent).
1518    if sframe_sessions().get(session_handle).is_none() {
1519        set_last_error("invalid session handle");
1520        return 0;
1521    }
1522    let Some(mls_arc) = mls().get(mls_handle) else {
1523        set_last_error("invalid MLS handle");
1524        return 0;
1525    };
1526    let mls = mls_arc.lock().unwrap();
1527    match SFrameSession::from_mls(&mls, label, suite) {
1528        Ok(session) => sframe_encryptors().insert(session.encryptor(leaf_index)),
1529        Err(e) => {
1530            set_last_error(e);
1531            0
1532        }
1533    }
1534}
1535
1536/// Frees an encryptor created by [`gbp_sframe_encryptor_create`].
1537#[unsafe(no_mangle)]
1538pub extern "C" fn gbp_sframe_encryptor_free(handle: i32) {
1539    sframe_encryptors().remove(handle);
1540}
1541
1542/// Encrypts one audio frame.
1543///
1544/// Returns a [`GbpBuffer`] containing `sframe_header ‖ ciphertext ‖ tag`.
1545/// The caller MUST free it with [`gbp_buffer_free`].
1546///
1547/// On error returns an empty buffer and sets [`gbp_last_error`].
1548///
1549/// * `aad_ptr` / `aad_len` — additional authenticated data (e.g. RTP header);
1550///   pass a null pointer and `0` if none.
1551///
1552/// # Safety
1553/// All pointer/length pairs MUST be valid for their respective lengths.
1554#[unsafe(no_mangle)]
1555pub unsafe extern "C" fn gbp_sframe_encrypt(
1556    enc_handle: i32,
1557    plaintext_ptr: *const u8,
1558    plaintext_len: usize,
1559    aad_ptr: *const u8,
1560    aad_len: usize,
1561) -> GbpBuffer {
1562    clear_last_error();
1563    let Some(enc_arc) = sframe_encryptors().get(enc_handle) else {
1564        set_last_error("invalid encryptor handle");
1565        return GbpBuffer::empty();
1566    };
1567    let plaintext = unsafe { std::slice::from_raw_parts(plaintext_ptr, plaintext_len) };
1568    let aad = if aad_ptr.is_null() || aad_len == 0 {
1569        &[][..]
1570    } else {
1571        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1572    };
1573    let mut enc = enc_arc.lock().unwrap();
1574    match enc.encrypt(plaintext, aad) {
1575        Ok(payload) => GbpBuffer::from_vec(payload),
1576        Err(e) => {
1577            set_last_error(e);
1578            GbpBuffer::empty()
1579        }
1580    }
1581}
1582
1583/// Decrypts one SFrame payload.
1584///
1585/// Returns a [`GbpBuffer`] containing the plaintext Opus frame.
1586/// The caller MUST free it with [`gbp_buffer_free`].
1587///
1588/// On success, `*sender_leaf_out` is set to the sender's leaf index.
1589/// On error returns an empty buffer and sets [`gbp_last_error`].
1590///
1591/// # Safety
1592/// All pointer/length pairs MUST be valid for their respective lengths.
1593/// `sender_leaf_out` MUST be a valid non-null pointer to a `u32`.
1594#[unsafe(no_mangle)]
1595pub unsafe extern "C" fn gbp_sframe_decrypt(
1596    session_handle: i32,
1597    payload_ptr: *const u8,
1598    payload_len: usize,
1599    aad_ptr: *const u8,
1600    aad_len: usize,
1601    sender_leaf_out: *mut u32,
1602) -> GbpBuffer {
1603    clear_last_error();
1604    let Some(session_arc) = sframe_sessions().get(session_handle) else {
1605        set_last_error("invalid session handle");
1606        return GbpBuffer::empty();
1607    };
1608    let payload = unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) };
1609    let aad = if aad_ptr.is_null() || aad_len == 0 {
1610        &[][..]
1611    } else {
1612        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1613    };
1614    let mut dec = session_arc.lock().unwrap();
1615    match dec.decrypt(payload, aad) {
1616        Ok((plaintext, leaf)) => {
1617            if !sender_leaf_out.is_null() {
1618                unsafe {
1619                    *sender_leaf_out = leaf;
1620                }
1621            }
1622            GbpBuffer::from_vec(plaintext)
1623        }
1624        Err(e) => {
1625            set_last_error(e);
1626            GbpBuffer::empty()
1627        }
1628    }
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633    use super::b64;
1634
1635    #[test]
1636    fn b64_empty() {
1637        assert_eq!(b64(b""), "");
1638    }
1639
1640    #[test]
1641    fn b64_single_byte() {
1642        // 0xFF = "255" → base64: b"255" → "//8="
1643        // 0xFF >> 2 = 63 = '/', (0xFF & 0x03) << 4 = 60 = '8', pad = "=="
1644        // "255" in base64: first char = table[(255 >> 2)] = table[63] = '/'
1645        // second char = table[((255 & 3) << 4) | 0] = table[60] = '8'
1646        // pad = "==" → "//8="
1647        // Actually let me just check with known vectors.
1648        let s = b64(b"f");
1649        // "f" = 0x66 = 102. chars: table[102>>2=25]='Z', table[((102&3)<<4)=32]='g', pad="==" → "Zg=="
1650        assert_eq!(s, "Zg==");
1651    }
1652
1653    #[test]
1654    fn b64_two_bytes() {
1655        let s = b64(b"fo");
1656        // "fo" → 0x66, 0x6F. n = (0x66<<16)|(0x6F<<8)|0 = 0x666F00
1657        // c1 = table[n>>18] = table[0x666F00>>18=1] = 'B' ... let me just use a known-vector.
1658        // Actually "fo" in base64 is "Zm8="  (0x66='Z', combined='m8', pad='=')
1659        assert_eq!(s, "Zm8=");
1660    }
1661
1662    #[test]
1663    fn b64_three_bytes() {
1664        let s = b64(b"foo");
1665        // "foo" in standard base64 is "Zm9v"
1666        assert_eq!(s, "Zm9v");
1667    }
1668
1669    #[test]
1670    fn b64_known_vectors() {
1671        // Independently verified against RFC 4648 test vectors and Python base64.
1672        assert_eq!(b64(b""), "");
1673        assert_eq!(b64(b"f"), "Zg==");
1674        assert_eq!(b64(b"fo"), "Zm8=");
1675        assert_eq!(b64(b"foo"), "Zm9v");
1676        assert_eq!(b64(b"foob"), "Zm9vYg==");
1677        assert_eq!(b64(b"fooba"), "Zm9vYmE=");
1678        assert_eq!(b64(b"foobar"), "Zm9vYmFy");
1679    }
1680
1681    #[test]
1682    fn b64_padding_roundtrip() {
1683        // Verify padding is correct by checking every single-byte input 0..255
1684        for b in 0u8..=255 {
1685            let input = [b];
1686            let enc = b64(&input);
1687            // Every 1-byte base64 string is exactly 4 chars.
1688            assert_eq!(enc.len(), 4, "len mismatch for 0x{b:02X}: {enc}");
1689            // The last two chars must be padding.
1690            assert!(enc.ends_with("=="), "missing padding for 0x{b:02X}: {enc}");
1691        }
1692    }
1693}