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    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
807    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
808    // not `MutexGuard<MlsContext>`, implements `Sealer`).
809    #[allow(clippy::explicit_auto_deref)]
810    match c.send(&mut n, &mut *m, target, message_id, text, codec) {
811        Ok(of) => outbound_to_buffer(of),
812        Err(e) => {
813            set_last_error(e.to_string());
814            GbpBuffer::empty()
815        }
816    }
817}
818
819/// Accepts a plaintext payload that the GBP layer surfaced via a
820/// `payload_received` event. Returns a JSON object of the form
821/// `{"status":"new|duplicate|error", ...}`.
822///
823/// `current_epoch` is the receiver node's current epoch — the client uses
824/// it to auto-reset its idempotency state when the epoch advances.
825/// `codec` must match the value from the `DeliveredPayload::codec` field
826/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
827///
828/// # Safety
829/// `pt_ptr` MUST be valid for `pt_len` bytes.
830#[unsafe(no_mangle)]
831pub unsafe extern "C" fn gtp_client_accept(
832    ch: i32,
833    current_epoch: u64,
834    pt_ptr: *const u8,
835    pt_len: usize,
836    codec: u8,
837) -> *mut c_char {
838    clear_last_error();
839    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
840    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
841    let Some(c_arc) = gtps().get(ch) else {
842        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
843    };
844    let mut c = c_arc.lock().unwrap();
845    #[derive(Serialize)]
846    struct Out<'a> {
847        status: &'a str,
848        sender: Option<u32>,
849        message_id: Option<u64>,
850        text: Option<String>,
851        reason: Option<String>,
852    }
853    let out = match c.accept(pt, current_epoch, codec) {
854        Ok(GtpAccept::New(m)) => Out {
855            status: "new",
856            sender: Some(m.sender_id),
857            message_id: Some(m.message_id),
858            text: Some(m.text().unwrap_or("<binary>").to_string()),
859            reason: None,
860        },
861        Ok(GtpAccept::Duplicate(m)) => Out {
862            status: "duplicate",
863            sender: Some(m.sender_id),
864            message_id: Some(m.message_id),
865            text: Some(m.text().unwrap_or("<binary>").to_string()),
866            reason: None,
867        },
868        Err(e) => Out {
869            status: "error",
870            sender: None,
871            message_id: None,
872            text: None,
873            reason: Some(e.to_string()),
874        },
875    };
876    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
877}
878
879// ============================================================================
880// GAP client API
881// ============================================================================
882
883/// Creates a stateful GAP client.
884#[unsafe(no_mangle)]
885pub extern "C" fn gap_client_create() -> i32 {
886    gaps().insert(GapClient::new())
887}
888
889/// Destroys a GAP client.
890#[unsafe(no_mangle)]
891pub extern "C" fn gap_client_destroy(h: i32) {
892    gaps().remove(h);
893}
894
895/// Clears the client state. Intended for use after an epoch change.
896#[unsafe(no_mangle)]
897pub extern "C" fn gap_client_reset(h: i32) {
898    if let Some(c) = gaps().get(h) {
899        c.lock().unwrap().reset();
900    }
901}
902
903/// Sends an Opus audio frame via GAP.
904///
905/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
906/// 2 = FlatBuffers. For audio use FlatBuffers (2) to minimize decode latency.
907///
908/// # Safety
909/// `opus_ptr` MUST be valid for `opus_len` bytes.
910#[unsafe(no_mangle)]
911pub unsafe extern "C" fn gap_client_send(
912    ch: i32,
913    nh: i32,
914    mh: i32,
915    target: u32,
916    media_source_id: u32,
917    rtp_timestamp: u64,
918    opus_ptr: *const u8,
919    opus_len: usize,
920    codec: u8,
921) -> GbpBuffer {
922    clear_last_error();
923    let opus = unsafe { std::slice::from_raw_parts(opus_ptr, opus_len) }.to_vec();
924    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
925    let (c_arc, n_arc, m_arc) = (gaps().get(ch), nodes().get(nh), mls().get(mh));
926    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
927        set_last_error("bad handle");
928        return GbpBuffer::empty();
929    };
930    let mut c = c_arc.lock().unwrap();
931    let mut n = n_arc.lock().unwrap();
932    let mut m = m_arc.lock().unwrap();
933    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
934    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
935    // not `MutexGuard<MlsContext>`, implements `Sealer`).
936    #[allow(clippy::explicit_auto_deref)]
937    match c.send(
938        &mut n,
939        &mut *m,
940        target,
941        media_source_id,
942        rtp_timestamp,
943        opus,
944        codec,
945    ) {
946        Ok(of) => outbound_to_buffer(of),
947        Err(e) => {
948            set_last_error(e.to_string());
949            GbpBuffer::empty()
950        }
951    }
952}
953
954/// Accepts a GAP audio payload.
955///
956/// `codec` must match the value from the `DeliveredPayload::codec` field
957/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
958///
959/// # Safety
960/// `pt_ptr` MUST be valid for `pt_len` bytes.
961#[unsafe(no_mangle)]
962pub unsafe extern "C" fn gap_client_accept(
963    ch: i32,
964    current_epoch: u64,
965    pt_ptr: *const u8,
966    pt_len: usize,
967    codec: u8,
968) -> *mut c_char {
969    clear_last_error();
970    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
971    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
972    let Some(c_arc) = gaps().get(ch) else {
973        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
974    };
975    let mut c = c_arc.lock().unwrap();
976    #[derive(Serialize)]
977    struct Out<'a> {
978        status: &'a str,
979        source: Option<u32>,
980        seq: Option<u32>,
981        bytes: Option<usize>,
982        reason: Option<String>,
983    }
984    let out = match c.accept(pt, current_epoch, codec) {
985        Ok(GapAccept::New(p)) => Out {
986            status: "new",
987            source: Some(p.media_source_id),
988            seq: Some(p.rtp_sequence),
989            bytes: Some(p.opus_frame.len()),
990            reason: None,
991        },
992        Ok(GapAccept::Late(p)) => Out {
993            status: "late",
994            source: Some(p.media_source_id),
995            seq: Some(p.rtp_sequence),
996            bytes: Some(p.opus_frame.len()),
997            reason: None,
998        },
999        Err(e) => Out {
1000            status: "error",
1001            source: None,
1002            seq: None,
1003            bytes: None,
1004            reason: Some(e.to_string()),
1005        },
1006    };
1007    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1008}
1009
1010// ============================================================================
1011// GSP client API
1012// ============================================================================
1013
1014/// Creates a stateful GSP client.
1015#[unsafe(no_mangle)]
1016pub extern "C" fn gsp_client_create() -> i32 {
1017    gsps().insert(GspClient::new())
1018}
1019
1020/// Destroys a GSP client.
1021#[unsafe(no_mangle)]
1022pub extern "C" fn gsp_client_destroy(h: i32) {
1023    gsps().remove(h);
1024}
1025
1026/// Clears the client state. Intended for use after an epoch change.
1027#[unsafe(no_mangle)]
1028pub extern "C" fn gsp_client_reset(h: i32) {
1029    if let Some(c) = gsps().get(h) {
1030        c.lock().unwrap().reset();
1031    }
1032}
1033
1034/// Sends a GSP signal.
1035///
1036/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
1037/// 2 = FlatBuffers. Unknown values fall back to CBOR.
1038#[unsafe(no_mangle)]
1039pub extern "C" fn gsp_client_send(
1040    ch: i32,
1041    nh: i32,
1042    mh: i32,
1043    target: u32,
1044    signal_type: u32,
1045    role_claim: u32,
1046    request_id: u32,
1047    codec: u8,
1048) -> GbpBuffer {
1049    clear_last_error();
1050    let sig = match SignalType::try_from(signal_type) {
1051        Ok(s) => s,
1052        Err(_) => {
1053            set_last_error(format!("bad signal {signal_type}"));
1054            return GbpBuffer::empty();
1055        }
1056    };
1057    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1058    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1059    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1060        set_last_error("bad handle");
1061        return GbpBuffer::empty();
1062    };
1063    let mut c = c_arc.lock().unwrap();
1064    let mut n = n_arc.lock().unwrap();
1065    let mut m = m_arc.lock().unwrap();
1066    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
1067    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
1068    // not `MutexGuard<MlsContext>`, implements `Sealer`).
1069    #[allow(clippy::explicit_auto_deref)]
1070    match c.send(&mut n, &mut *m, target, sig, role_claim, request_id, codec) {
1071        Ok(of) => outbound_to_buffer(of),
1072        Err(e) => {
1073            set_last_error(e.to_string());
1074            GbpBuffer::empty()
1075        }
1076    }
1077}
1078
1079/// Sends a GSP signal with opcode-specific CBOR `args` bytes.
1080/// Use this for signals that require structured arguments (MUTE, UNMUTE,
1081/// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE).
1082///
1083/// `codec` selects the payload encoding: 0 = CBOR (default), 1 = Protobuf,
1084/// 2 = FlatBuffers. Unknown values fall back to CBOR.
1085///
1086/// # Safety
1087/// `args_ptr` MUST be valid for `args_len` bytes.
1088#[unsafe(no_mangle)]
1089pub unsafe extern "C" fn gsp_client_send_with_args(
1090    ch: i32,
1091    nh: i32,
1092    mh: i32,
1093    target: u32,
1094    signal_type: u32,
1095    role_claim: u32,
1096    request_id: u32,
1097    args_ptr: *const u8,
1098    args_len: usize,
1099    codec: u8,
1100) -> GbpBuffer {
1101    clear_last_error();
1102    let args: &[u8] = if args_len == 0 || args_ptr.is_null() {
1103        &[]
1104    } else {
1105        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }
1106    };
1107    let sig = match SignalType::try_from(signal_type) {
1108        Ok(s) => s,
1109        Err(_) => {
1110            set_last_error(format!("bad signal {signal_type}"));
1111            return GbpBuffer::empty();
1112        }
1113    };
1114    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1115    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1116    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1117        set_last_error("bad handle");
1118        return GbpBuffer::empty();
1119    };
1120    let mut c = c_arc.lock().unwrap();
1121    let mut n = n_arc.lock().unwrap();
1122    let mut m = m_arc.lock().unwrap();
1123    // `&mut *m` is required, not redundant: `send_with_args`'s `seal: &mut S` is
1124    // generic, and deref coercion doesn't apply across a generic bound (only
1125    // `MlsContext`, not `MutexGuard<MlsContext>`, implements `Sealer`).
1126    #[allow(clippy::explicit_auto_deref)]
1127    match c.send_with_args(
1128        &mut n, &mut *m, target, sig, role_claim, request_id, args, codec,
1129    ) {
1130        Ok(of) => outbound_to_buffer(of),
1131        Err(e) => {
1132            set_last_error(e.to_string());
1133            GbpBuffer::empty()
1134        }
1135    }
1136}
1137
1138/// Accepts a GSP signal payload.
1139///
1140/// `current_epoch` is the receiver node's current epoch — the client uses
1141/// it to auto-reset its dedup state when the epoch advances.
1142/// `codec` must match the value from the `DeliveredPayload::codec` field
1143/// (0 = CBOR, 1 = Protobuf, 2 = FlatBuffers).
1144///
1145/// # Safety
1146/// `pt_ptr` MUST be valid for `pt_len` bytes.
1147#[unsafe(no_mangle)]
1148pub unsafe extern "C" fn gsp_client_accept(
1149    ch: i32,
1150    current_epoch: u64,
1151    pt_ptr: *const u8,
1152    pt_len: usize,
1153    codec: u8,
1154) -> *mut c_char {
1155    clear_last_error();
1156    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
1157    let codec = PayloadCodec::from_u8(codec).unwrap_or(PayloadCodec::Cbor);
1158    let Some(c_arc) = gsps().get(ch) else {
1159        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
1160    };
1161    let mut c = c_arc.lock().unwrap();
1162    #[derive(Serialize)]
1163    struct Out<'a> {
1164        status: &'a str,
1165        signal: Option<&'a str>,
1166        signal_code: Option<u32>,
1167        sender: Option<u32>,
1168        role_claim: Option<u32>,
1169        request_id: Option<u32>,
1170        reason: Option<String>,
1171    }
1172    let out = match c.accept(pt, current_epoch, codec) {
1173        Ok(GspAccept {
1174            signal,
1175            sender_id,
1176            role_claim,
1177            request_id,
1178        }) => Out {
1179            status: "new",
1180            signal: Some(signal.name()),
1181            signal_code: Some(signal as u32),
1182            sender: Some(sender_id),
1183            role_claim: Some(role_claim),
1184            request_id: Some(request_id),
1185            reason: None,
1186        },
1187        Err(gbp_stack::GspError::DuplicateRequest(rid)) => Out {
1188            status: "duplicate",
1189            signal: None,
1190            signal_code: None,
1191            sender: None,
1192            role_claim: None,
1193            request_id: Some(rid),
1194            reason: None,
1195        },
1196        Err(e) => Out {
1197            status: "error",
1198            signal: None,
1199            signal_code: None,
1200            sender: None,
1201            role_claim: None,
1202            request_id: None,
1203            reason: Some(e.to_string()),
1204        },
1205    };
1206    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1207}
1208
1209// ============================================================================
1210// Codec helpers (used for tests that need malformed frames)
1211// ============================================================================
1212
1213/// CBOR-encodes a [`gbp::GbpFrame`] with an arbitrary version byte.
1214///
1215/// # Safety
1216/// Every pointer MUST be valid for the corresponding declared length.
1217#[unsafe(no_mangle)]
1218pub unsafe extern "C" fn gbp_frame_encode_v(
1219    version: u8,
1220    group_id_16: *const u8,
1221    epoch: u64,
1222    transition_id: u32,
1223    stream_type: u32,
1224    stream_id: u32,
1225    flags: u16,
1226    sequence_no: u32,
1227    payload_ptr: *const u8,
1228    payload_len: usize,
1229) -> GbpBuffer {
1230    clear_last_error();
1231    let mut gid = [0u8; 16];
1232    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
1233    let st_u8 = StreamType::try_from(stream_type)
1234        .map(|s| s as u8)
1235        .unwrap_or(stream_type as u8);
1236    let payload: Vec<u8> = if payload_len == 0 || payload_ptr.is_null() {
1237        Vec::new()
1238    } else {
1239        unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) }.to_vec()
1240    };
1241    let frame = gbp_stack::gbp::GbpFrame {
1242        version,
1243        group_id: serde_bytes::ByteBuf::from(gid.to_vec()),
1244        epoch,
1245        transition_id,
1246        stream_type: st_u8,
1247        stream_id,
1248        flags,
1249        sequence_no,
1250        payload_format: 0u8,
1251        payload_size: payload.len() as u32,
1252        encrypted_payload: serde_bytes::ByteBuf::from(payload),
1253    };
1254    GbpBuffer::from_vec(frame.to_cbor())
1255}
1256
1257/// Returns a CBOR-encoded `ErrorObject` for the given code.
1258#[unsafe(no_mangle)]
1259pub extern "C" fn gbp_error_lookup(code: u16) -> GbpBuffer {
1260    use gbp_stack::core::errors::ErrorSpec;
1261    match ErrorSpec::lookup(code) {
1262        Some(spec) => GbpBuffer::from_vec(ErrorObject::from_spec(spec, spec.name).to_cbor()),
1263        None => {
1264            set_last_error(format!("unknown error code 0x{code:04X}"));
1265            GbpBuffer::empty()
1266        }
1267    }
1268}
1269
1270#[allow(dead_code)]
1271fn _link(_f: &GbpFrame, _l: StreamLabel) {}
1272
1273// ============================================================================
1274// Event JSON
1275// ============================================================================
1276
1277#[derive(Serialize)]
1278#[serde(tag = "kind", rename_all = "snake_case")]
1279enum EventDto<'a> {
1280    StateChanged {
1281        from: String,
1282        to: String,
1283    },
1284    PayloadReceived {
1285        stream_type: &'a str,
1286        stream_type_code: u32,
1287        stream_id: u32,
1288        sequence_no: u32,
1289        flags: u16,
1290        codec: u8,
1291        plaintext_b64: String,
1292    },
1293    Control {
1294        from: u32,
1295        opcode: &'a str,
1296        opcode_code: u16,
1297        transition_id: u32,
1298        request_id: u32,
1299        args_b64: String,
1300    },
1301    Error {
1302        code: u16,
1303        code_hex: String,
1304        class: u8,
1305        retryable: bool,
1306        fatal: bool,
1307        reason: String,
1308    },
1309    EpochAdvanced {
1310        epoch: u64,
1311        transition_id: u32,
1312    },
1313    CoordinatorElectionNeeded {},
1314    BecameCoordinator {},
1315    CoordinatorClaim {
1316        claimant: u32,
1317    },
1318}
1319
1320fn b64(b: &[u8]) -> String {
1321    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1322    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
1323    let mut i = 0;
1324    while i + 3 <= b.len() {
1325        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8) | (b[i + 2] as u32);
1326        out.push(A[(n >> 18) as usize & 0x3F] as char);
1327        out.push(A[(n >> 12) as usize & 0x3F] as char);
1328        out.push(A[(n >> 6) as usize & 0x3F] as char);
1329        out.push(A[n as usize & 0x3F] as char);
1330        i += 3;
1331    }
1332    let rem = b.len() - i;
1333    if rem == 1 {
1334        let n = (b[i] as u32) << 16;
1335        out.push(A[(n >> 18) as usize & 0x3F] as char);
1336        out.push(A[(n >> 12) as usize & 0x3F] as char);
1337        out.push('=');
1338        out.push('=');
1339    } else if rem == 2 {
1340        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8);
1341        out.push(A[(n >> 18) as usize & 0x3F] as char);
1342        out.push(A[(n >> 12) as usize & 0x3F] as char);
1343        out.push(A[(n >> 6) as usize & 0x3F] as char);
1344        out.push('=');
1345    }
1346    out
1347}
1348
1349fn dto<'a>(e: &'a Event) -> EventDto<'a> {
1350    match e {
1351        Event::StateChanged { from, to } => EventDto::StateChanged {
1352            from: from.to_string(),
1353            to: to.to_string(),
1354        },
1355        Event::PayloadReceived(DeliveredPayload {
1356            stream_type,
1357            stream_id,
1358            sequence_no,
1359            flags,
1360            plaintext,
1361            codec,
1362        }) => EventDto::PayloadReceived {
1363            stream_type: match stream_type {
1364                StreamType::Control => "control",
1365                StreamType::Audio => "audio",
1366                StreamType::Text => "text",
1367                StreamType::Signal => "signal",
1368            },
1369            stream_type_code: *stream_type as u32,
1370            stream_id: *stream_id,
1371            sequence_no: *sequence_no,
1372            flags: *flags,
1373            codec: codec.as_u8(),
1374            plaintext_b64: b64(plaintext),
1375        },
1376        Event::Control {
1377            from,
1378            opcode,
1379            transition_id,
1380            request_id,
1381            args,
1382        } => EventDto::Control {
1383            from: *from,
1384            opcode: opcode.name(),
1385            opcode_code: *opcode as u16,
1386            transition_id: *transition_id,
1387            request_id: *request_id,
1388            args_b64: b64(args),
1389        },
1390        Event::Error {
1391            code,
1392            class,
1393            retryable,
1394            fatal,
1395            reason,
1396        } => EventDto::Error {
1397            code: *code,
1398            code_hex: format!("0x{code:04X}"),
1399            class: *class as u8,
1400            retryable: *retryable,
1401            fatal: *fatal,
1402            reason: reason.clone(),
1403        },
1404        Event::EpochAdvanced {
1405            epoch,
1406            transition_id,
1407        } => EventDto::EpochAdvanced {
1408            epoch: *epoch,
1409            transition_id: *transition_id,
1410        },
1411        Event::CoordinatorElectionNeeded => EventDto::CoordinatorElectionNeeded {},
1412        Event::BecameCoordinator => EventDto::BecameCoordinator {},
1413        Event::CoordinatorClaim { claimant } => EventDto::CoordinatorClaim {
1414            claimant: *claimant,
1415        },
1416    }
1417}
1418
1419fn events_to_json(events: &[Event]) -> String {
1420    let dtos: Vec<EventDto> = events.iter().map(dto).collect();
1421    serde_json::to_string(&dtos).unwrap_or_else(|_| "[]".to_string())
1422}
1423
1424#[allow(dead_code)]
1425const _STATES: [NodeState; 7] = [
1426    NodeState::Idle,
1427    NodeState::Connecting,
1428    NodeState::EstablishingGroup,
1429    NodeState::Active,
1430    NodeState::Resyncing,
1431    NodeState::Failed,
1432    NodeState::Closed,
1433];
1434
1435// ============================================================================
1436// SFrame API  (`gbp_sframe_*`)
1437// ============================================================================
1438
1439/// Creates an SFrame session from an existing MLS context.
1440///
1441/// Derives `sframe_base_key = MLS.ExportSecret(label, epoch_be8, 32)` and
1442/// stores a [`SFrameDecryptor`] in the session registry.  Each SFrame
1443/// session corresponds to one MLS epoch; create a new session after every
1444/// commit.
1445///
1446/// Returns a positive session handle, or `0` on failure (check
1447/// [`gbp_last_error`]).
1448///
1449/// * `mls_handle` — handle from [`gbp_mls_create`].
1450/// * `suite` — `0` = AES-128-GCM, `1` = AES-256-GCM.
1451/// * `label_ptr` / `label_len` — UTF-8 export label (e.g. `"gbp/sframe v1"`).
1452///
1453/// # Safety
1454/// `label_ptr` MUST be valid UTF-8 for `label_len` bytes.
1455#[unsafe(no_mangle)]
1456pub unsafe extern "C" fn gbp_sframe_session_create(
1457    mls_handle: i32,
1458    suite: u8,
1459    label_ptr: *const u8,
1460    label_len: usize,
1461) -> i32 {
1462    clear_last_error();
1463    let suite = match CipherSuite::from_u8(suite) {
1464        Some(s) => s,
1465        None => {
1466            set_last_error(format!("unknown ciphersuite {suite}"));
1467            return 0;
1468        }
1469    };
1470    let label = unsafe {
1471        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1472            Ok(s) => s,
1473            Err(e) => {
1474                set_last_error(e);
1475                return 0;
1476            }
1477        }
1478    };
1479    let Some(mls_arc) = mls().get(mls_handle) else {
1480        set_last_error("invalid MLS handle");
1481        return 0;
1482    };
1483    let mls = mls_arc.lock().unwrap();
1484    match SFrameSession::from_mls(&mls, label, suite) {
1485        Ok(session) => sframe_sessions().insert(session.decryptor()),
1486        Err(e) => {
1487            set_last_error(e);
1488            0
1489        }
1490    }
1491}
1492
1493/// Frees an SFrame session created by [`gbp_sframe_session_create`].
1494#[unsafe(no_mangle)]
1495pub extern "C" fn gbp_sframe_session_free(handle: i32) {
1496    sframe_sessions().remove(handle);
1497}
1498
1499/// Creates an encryptor for the local sender (`leaf_index`) within an epoch.
1500///
1501/// The session handle MUST be the one returned by [`gbp_sframe_session_create`]
1502/// for the same epoch.  One encryptor per sender; do **not** share across
1503/// threads.
1504///
1505/// Returns a positive encryptor handle, or `0` on failure.
1506///
1507/// # Safety
1508/// `mls_handle` and `session_handle` must be valid.
1509#[unsafe(no_mangle)]
1510pub unsafe extern "C" fn gbp_sframe_encryptor_create(
1511    mls_handle: i32,
1512    session_handle: i32,
1513    leaf_index: u32,
1514    suite: u8,
1515    label_ptr: *const u8,
1516    label_len: usize,
1517) -> i32 {
1518    clear_last_error();
1519    let suite = match CipherSuite::from_u8(suite) {
1520        Some(s) => s,
1521        None => {
1522            set_last_error(format!("unknown ciphersuite {suite}"));
1523            return 0;
1524        }
1525    };
1526    let label = unsafe {
1527        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1528            Ok(s) => s,
1529            Err(e) => {
1530                set_last_error(e);
1531                return 0;
1532            }
1533        }
1534    };
1535    // Verify the session handle exists (keeps the API consistent).
1536    if sframe_sessions().get(session_handle).is_none() {
1537        set_last_error("invalid session handle");
1538        return 0;
1539    }
1540    let Some(mls_arc) = mls().get(mls_handle) else {
1541        set_last_error("invalid MLS handle");
1542        return 0;
1543    };
1544    let mls = mls_arc.lock().unwrap();
1545    match SFrameSession::from_mls(&mls, label, suite) {
1546        Ok(session) => sframe_encryptors().insert(session.encryptor(leaf_index)),
1547        Err(e) => {
1548            set_last_error(e);
1549            0
1550        }
1551    }
1552}
1553
1554/// Frees an encryptor created by [`gbp_sframe_encryptor_create`].
1555#[unsafe(no_mangle)]
1556pub extern "C" fn gbp_sframe_encryptor_free(handle: i32) {
1557    sframe_encryptors().remove(handle);
1558}
1559
1560/// Encrypts one audio frame.
1561///
1562/// Returns a [`GbpBuffer`] containing `sframe_header ‖ ciphertext ‖ tag`.
1563/// The caller MUST free it with [`gbp_buffer_free`].
1564///
1565/// On error returns an empty buffer and sets [`gbp_last_error`].
1566///
1567/// * `aad_ptr` / `aad_len` — additional authenticated data (e.g. RTP header);
1568///   pass a null pointer and `0` if none.
1569///
1570/// # Safety
1571/// All pointer/length pairs MUST be valid for their respective lengths.
1572#[unsafe(no_mangle)]
1573pub unsafe extern "C" fn gbp_sframe_encrypt(
1574    enc_handle: i32,
1575    plaintext_ptr: *const u8,
1576    plaintext_len: usize,
1577    aad_ptr: *const u8,
1578    aad_len: usize,
1579) -> GbpBuffer {
1580    clear_last_error();
1581    let Some(enc_arc) = sframe_encryptors().get(enc_handle) else {
1582        set_last_error("invalid encryptor handle");
1583        return GbpBuffer::empty();
1584    };
1585    let plaintext = unsafe { std::slice::from_raw_parts(plaintext_ptr, plaintext_len) };
1586    let aad = if aad_ptr.is_null() || aad_len == 0 {
1587        &[][..]
1588    } else {
1589        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1590    };
1591    let mut enc = enc_arc.lock().unwrap();
1592    match enc.encrypt(plaintext, aad) {
1593        Ok(payload) => GbpBuffer::from_vec(payload),
1594        Err(e) => {
1595            set_last_error(e);
1596            GbpBuffer::empty()
1597        }
1598    }
1599}
1600
1601/// Decrypts one SFrame payload.
1602///
1603/// Returns a [`GbpBuffer`] containing the plaintext Opus frame.
1604/// The caller MUST free it with [`gbp_buffer_free`].
1605///
1606/// On success, `*sender_leaf_out` is set to the sender's leaf index.
1607/// On error returns an empty buffer and sets [`gbp_last_error`].
1608///
1609/// # Safety
1610/// All pointer/length pairs MUST be valid for their respective lengths.
1611/// `sender_leaf_out` MUST be a valid non-null pointer to a `u32`.
1612#[unsafe(no_mangle)]
1613pub unsafe extern "C" fn gbp_sframe_decrypt(
1614    session_handle: i32,
1615    payload_ptr: *const u8,
1616    payload_len: usize,
1617    aad_ptr: *const u8,
1618    aad_len: usize,
1619    sender_leaf_out: *mut u32,
1620) -> GbpBuffer {
1621    clear_last_error();
1622    let Some(session_arc) = sframe_sessions().get(session_handle) else {
1623        set_last_error("invalid session handle");
1624        return GbpBuffer::empty();
1625    };
1626    let payload = unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) };
1627    let aad = if aad_ptr.is_null() || aad_len == 0 {
1628        &[][..]
1629    } else {
1630        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1631    };
1632    let mut dec = session_arc.lock().unwrap();
1633    match dec.decrypt(payload, aad) {
1634        Ok((plaintext, leaf)) => {
1635            if !sender_leaf_out.is_null() {
1636                unsafe {
1637                    *sender_leaf_out = leaf;
1638                }
1639            }
1640            GbpBuffer::from_vec(plaintext)
1641        }
1642        Err(e) => {
1643            set_last_error(e);
1644            GbpBuffer::empty()
1645        }
1646    }
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::b64;
1652
1653    #[test]
1654    fn b64_empty() {
1655        assert_eq!(b64(b""), "");
1656    }
1657
1658    #[test]
1659    fn b64_single_byte() {
1660        // 0xFF = "255" → base64: b"255" → "//8="
1661        // 0xFF >> 2 = 63 = '/', (0xFF & 0x03) << 4 = 60 = '8', pad = "=="
1662        // "255" in base64: first char = table[(255 >> 2)] = table[63] = '/'
1663        // second char = table[((255 & 3) << 4) | 0] = table[60] = '8'
1664        // pad = "==" → "//8="
1665        // Actually let me just check with known vectors.
1666        let s = b64(b"f");
1667        // "f" = 0x66 = 102. chars: table[102>>2=25]='Z', table[((102&3)<<4)=32]='g', pad="==" → "Zg=="
1668        assert_eq!(s, "Zg==");
1669    }
1670
1671    #[test]
1672    fn b64_two_bytes() {
1673        let s = b64(b"fo");
1674        // "fo" → 0x66, 0x6F. n = (0x66<<16)|(0x6F<<8)|0 = 0x666F00
1675        // c1 = table[n>>18] = table[0x666F00>>18=1] = 'B' ... let me just use a known-vector.
1676        // Actually "fo" in base64 is "Zm8="  (0x66='Z', combined='m8', pad='=')
1677        assert_eq!(s, "Zm8=");
1678    }
1679
1680    #[test]
1681    fn b64_three_bytes() {
1682        let s = b64(b"foo");
1683        // "foo" in standard base64 is "Zm9v"
1684        assert_eq!(s, "Zm9v");
1685    }
1686
1687    #[test]
1688    fn b64_known_vectors() {
1689        // Independently verified against RFC 4648 test vectors and Python base64.
1690        assert_eq!(b64(b""), "");
1691        assert_eq!(b64(b"f"), "Zg==");
1692        assert_eq!(b64(b"fo"), "Zm8=");
1693        assert_eq!(b64(b"foo"), "Zm9v");
1694        assert_eq!(b64(b"foob"), "Zm9vYg==");
1695        assert_eq!(b64(b"fooba"), "Zm9vYmE=");
1696        assert_eq!(b64(b"foobar"), "Zm9vYmFy");
1697    }
1698
1699    #[test]
1700    fn b64_padding_roundtrip() {
1701        // Verify padding is correct by checking every single-byte input 0..255
1702        for b in 0u8..=255 {
1703            let input = [b];
1704            let enc = b64(&input);
1705            // Every 1-byte base64 string is exactly 4 chars.
1706            assert_eq!(enc.len(), 4, "len mismatch for 0x{b:02X}: {enc}");
1707            // The last two chars must be padding.
1708            assert!(enc.ends_with("=="), "missing padding for 0x{b:02X}: {enc}");
1709        }
1710    }
1711}