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