Skip to main content

net/ffi/
mesh.rs

1//! C FFI bindings for the encrypted-UDP mesh transport.
2//!
3//! Surface targeted at the Go SDK. Mirrors the Rust SDK's `Mesh`
4//! type (not the full core `MeshNode`) — just the common path:
5//! handshake, per-peer streams, channels, shard receive.
6//!
7//! Everything crosses the boundary as:
8//!
9//! - Opaque handles (`*mut T`) freed via dedicated `_free` functions.
10//! - Scalar ids as `u64`.
11//! - Everything else as JSON strings allocated with
12//!   `CString::into_raw`, freed by the caller via `net_free_string`.
13//!
14//! Handshake + per-peer sends are async on the core side; the FFI
15//! drives them via a shared `tokio::runtime::Runtime` (lazy OnceLock)
16//! identical to the one used by `ffi/cortex.rs`.
17
18use std::ffi::{c_char, c_int, CStr, CString};
19use std::mem::ManuallyDrop;
20use std::sync::Arc;
21
22use bytes::Bytes;
23use serde::{Deserialize, Serialize};
24use tokio::runtime::Runtime;
25
26use crate::adapter::net::identity::{
27    EntityId, PermissionToken, TokenCache, TokenError as CoreTokenError, TokenScope,
28};
29use crate::adapter::net::{
30    ChannelConfig as InnerChannelConfig, ChannelConfigRegistry, ChannelHash, ChannelId,
31    ChannelName as InnerChannelName, ChannelPublisher, EntityKeypair, MeshNode, MeshNodeConfig,
32    OnFailure as InnerOnFailure, PublishConfig as InnerPublishConfig,
33    PublishReport as InnerPublishReport, Reliability, Stream as CoreStream, StreamConfig,
34    StreamError, Visibility as InnerVisibility, DEFAULT_STREAM_WINDOW_BYTES,
35};
36use crate::adapter::net::{SubnetId, SubnetPolicy, SubnetRule};
37use crate::adapter::Adapter;
38use crate::error::AdapterError;
39
40use super::handle_guard::{HandleGuard, FFI_HANDLE_FREE_DEADLINE};
41use super::NetError;
42
43// =========================================================================
44// Mesh-specific error codes. Continues the -100..-99 range used by
45// `ffi/cortex.rs`. The Go layer maps these to typed sentinels.
46// =========================================================================
47
48pub(crate) const NET_ERR_MESH_INIT: c_int = -110;
49pub(crate) const NET_ERR_MESH_HANDSHAKE: c_int = -111;
50pub(crate) const NET_ERR_MESH_BACKPRESSURE: c_int = -112;
51pub(crate) const NET_ERR_MESH_NOT_CONNECTED: c_int = -113;
52pub(crate) const NET_ERR_MESH_TRANSPORT: c_int = -114;
53pub(crate) const NET_ERR_CHANNEL: c_int = -115;
54pub(crate) const NET_ERR_CHANNEL_AUTH: c_int = -116;
55
56// Identity + token error codes. Block -120..-129 mirrors the
57// `"identity: ..."` / `"token: <kind>"` prefix convention used by
58// PyO3 and NAPI; each `kind` gets its own integer so Go callers can
59// `errors.Is(err, net.ErrTokenExpired)` without parsing strings.
60pub(crate) const NET_ERR_IDENTITY: c_int = -120;
61pub(crate) const NET_ERR_TOKEN_INVALID_FORMAT: c_int = -121;
62pub(crate) const NET_ERR_TOKEN_INVALID_SIGNATURE: c_int = -122;
63pub(crate) const NET_ERR_TOKEN_EXPIRED: c_int = -123;
64pub(crate) const NET_ERR_TOKEN_NOT_YET_VALID: c_int = -124;
65pub(crate) const NET_ERR_TOKEN_DELEGATION_EXHAUSTED: c_int = -125;
66pub(crate) const NET_ERR_TOKEN_DELEGATION_NOT_ALLOWED: c_int = -126;
67pub(crate) const NET_ERR_TOKEN_NOT_AUTHORIZED: c_int = -127;
68
69// NAT-traversal error codes. Block -130..-139 — one integer per
70// `TraversalError::kind()` so Go callers can
71// `errors.Is(err, net.ErrTraversalPunchFailed)` without parsing
72// strings, matching the token-error pattern above. Framing (plan
73// §5): every `TraversalError` represents a missed *optimization*,
74// not a connectivity failure — the routed-handshake path is
75// always available. See `TraversalError` docs for per-variant
76// semantics.
77// Per-variant traversal error codes. Gated on the feature
78// because they're only referenced by `traversal_err_to_code`,
79// which only compiles with the feature on. `NET_ERR_TRAVERSAL_UNSUPPORTED`
80// below is unconditional — the no-feature stubs need it.
81#[cfg(feature = "nat-traversal")]
82pub(crate) const NET_ERR_TRAVERSAL_REFLEX_TIMEOUT: c_int = -130;
83#[cfg(feature = "nat-traversal")]
84pub(crate) const NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE: c_int = -131;
85#[cfg(feature = "nat-traversal")]
86pub(crate) const NET_ERR_TRAVERSAL_TRANSPORT: c_int = -132;
87#[cfg(feature = "nat-traversal")]
88pub(crate) const NET_ERR_TRAVERSAL_RENDEZVOUS_NO_RELAY: c_int = -133;
89#[cfg(feature = "nat-traversal")]
90pub(crate) const NET_ERR_TRAVERSAL_RENDEZVOUS_REJECTED: c_int = -134;
91#[cfg(feature = "nat-traversal")]
92pub(crate) const NET_ERR_TRAVERSAL_PUNCH_FAILED: c_int = -135;
93#[cfg(feature = "nat-traversal")]
94pub(crate) const NET_ERR_TRAVERSAL_PORT_MAP_UNAVAILABLE: c_int = -136;
95// Unconditional — the `#[cfg(not(feature = "nat-traversal"))]`
96// FFI stubs below return this so the Go / NAPI / PyO3 bindings
97// surface `ErrTraversalUnsupported` when built against a cdylib
98// without the feature, rather than failing at dlopen with a
99// missing-symbol error.
100pub(crate) const NET_ERR_TRAVERSAL_UNSUPPORTED: c_int = -137;
101
102#[cfg(feature = "nat-traversal")]
103fn traversal_err_to_code(e: &crate::adapter::net::traversal::TraversalError) -> c_int {
104    use crate::adapter::net::traversal::TraversalError;
105    match e {
106        TraversalError::ReflexTimeout => NET_ERR_TRAVERSAL_REFLEX_TIMEOUT,
107        TraversalError::PeerNotReachable => NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE,
108        TraversalError::Transport(_) => NET_ERR_TRAVERSAL_TRANSPORT,
109        TraversalError::RendezvousNoRelay => NET_ERR_TRAVERSAL_RENDEZVOUS_NO_RELAY,
110        TraversalError::RendezvousRejected(_) => NET_ERR_TRAVERSAL_RENDEZVOUS_REJECTED,
111        TraversalError::PunchFailed => NET_ERR_TRAVERSAL_PUNCH_FAILED,
112        TraversalError::PortMapUnavailable => NET_ERR_TRAVERSAL_PORT_MAP_UNAVAILABLE,
113        TraversalError::Unsupported => NET_ERR_TRAVERSAL_UNSUPPORTED,
114    }
115}
116
117/// Stable string form of a `NatClass`. Same vocabulary as the
118/// NAPI / PyO3 bindings — callers branch on
119/// `"open" | "cone" | "symmetric" | "unknown"`.
120#[cfg(feature = "nat-traversal")]
121fn nat_class_to_str(class: crate::adapter::net::traversal::classify::NatClass) -> &'static str {
122    use crate::adapter::net::traversal::classify::NatClass;
123    match class {
124        NatClass::Open => "open",
125        NatClass::Cone => "cone",
126        NatClass::Symmetric => "symmetric",
127        NatClass::Unknown => "unknown",
128    }
129}
130
131fn token_err_to_code(e: &CoreTokenError) -> c_int {
132    match e {
133        CoreTokenError::InvalidFormat => NET_ERR_TOKEN_INVALID_FORMAT,
134        CoreTokenError::InvalidSignature => NET_ERR_TOKEN_INVALID_SIGNATURE,
135        CoreTokenError::Expired => NET_ERR_TOKEN_EXPIRED,
136        CoreTokenError::NotYetValid => NET_ERR_TOKEN_NOT_YET_VALID,
137        CoreTokenError::DelegationExhausted => NET_ERR_TOKEN_DELEGATION_EXHAUSTED,
138        CoreTokenError::DelegationNotAllowed => NET_ERR_TOKEN_DELEGATION_NOT_ALLOWED,
139        CoreTokenError::NotAuthorized => NET_ERR_TOKEN_NOT_AUTHORIZED,
140        // Maps to `NET_ERR_IDENTITY` since a public-only keypair
141        // is fundamentally an identity-availability issue, not a
142        // token-content issue. The error message in `Display`
143        // makes the cause clear to the caller.
144        CoreTokenError::ReadOnly => NET_ERR_IDENTITY,
145        // A zero-TTL request is a malformed token-issue
146        // input. Routes to `NET_ERR_TOKEN_INVALID_FORMAT` (the
147        // closest existing semantic — invalid input shape) so
148        // the C/Go header surface stays unchanged. The Display
149        // message ("token TTL must be > 0 seconds") tells the
150        // caller exactly what was wrong.
151        CoreTokenError::ZeroTtl => NET_ERR_TOKEN_INVALID_FORMAT,
152    }
153}
154
155// =========================================================================
156// Shared utilities
157// =========================================================================
158
159/// Shared tokio runtime. One per process, lazy-initialized.
160///
161/// On `tokio::Builder::build()` failure (worker-thread
162/// `pthread_create` failure under `RLIMIT_NPROC` / container
163/// limits / memory pressure) we `eprintln! + std::process::abort()`
164/// rather than panic. `abort` is `extern "C"`-safe (terminates
165/// rather than unwinds), so the failure cannot escape across the
166/// surrounding `extern "C"` FFI frame into C / Go-cgo / NAPI /
167/// PyO3 callers — that would be undefined behaviour. A daemon
168/// that can't construct its async runtime is dead in the water,
169/// so termination is the appropriate response.
170fn runtime() -> &'static Arc<Runtime> {
171    use std::sync::OnceLock;
172    static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
173    RT.get_or_init(|| {
174        match tokio::runtime::Builder::new_multi_thread()
175            .enable_all()
176            .build()
177        {
178            Ok(rt) => Arc::new(rt),
179            Err(e) => {
180                eprintln!(
181                    "FATAL: mesh FFI tokio runtime build failure ({e:?}); aborting to avoid panic across the FFI boundary"
182                );
183                std::process::abort();
184            }
185        }
186    })
187}
188
189/// `block_on(...)` wrapper that aborts on runtime-in-runtime
190/// rather than panicking across the FFI boundary.
191///
192/// Calling `Runtime::block_on` from a thread that already holds a
193/// tokio runtime context panics with "Cannot start a runtime from
194/// within a runtime". The cortex / mesh FFI functions are
195/// `extern "C"`, so the panic would unwind across cgo / N-API / cffi
196/// — undefined behavior. The check costs one TLS lookup
197/// (`Handle::try_current`) per FFI call, which is negligible against
198/// the work the FFI is about to do (network I/O, JSON parsing,
199/// channel operations). Common-case callers (C / Go / Python without
200/// an embedding Rust runtime) hit the fast path; embedded-Rust
201/// callers who violate the contract get a clean abort with a
202/// diagnosable message instead of UB.
203fn block_on<F: std::future::Future>(future: F) -> F::Output {
204    if tokio::runtime::Handle::try_current().is_ok() {
205        eprintln!(
206            "FATAL: mesh FFI called from inside a tokio runtime context; \
207             aborting to avoid runtime-in-runtime panic across the FFI boundary"
208        );
209        std::process::abort();
210    }
211    runtime().block_on(future)
212}
213
214/// The output borrow's lifetime is tied (via Rust's elision rules)
215/// to the input reference's lifetime, so the caller cannot pick
216/// `'static` and produce a dangling borrow. The borrow lives only
217/// as long as the local stack frame holding the pointer — which is
218/// the caller's responsibility to keep valid for the duration of
219/// any resulting `&str` use, but no longer. Compare
220/// `cortex.rs::c_str_to_owned` which sidesteps the issue entirely
221/// by returning `Option<String>`.
222///
223/// Returns an OWNED `String` (not a borrowed `&str` tied to the C
224/// buffer). The previous `Option<&str>` signature was a soundness
225/// trap: lifetime elision on `&*const c_char` bound the returned
226/// `&str` to the local pointer reference's stack slot rather than
227/// to the underlying C buffer, so a future refactor that moved the
228/// result into `tokio::spawn(async move { ... })` would compile
229/// silently and hand a dangling pointer to the spawned task. The
230/// owned-`String` shape removes the hazard at the cost of one
231/// allocation per call, which is acceptable on FFI entry paths.
232///
233/// # Safety
234/// Caller must ensure `p` is null or points to a NUL-terminated C
235/// string valid at least until this function returns.
236#[inline]
237pub(super) unsafe fn c_str_to_string(p: *const c_char) -> Option<String> {
238    if p.is_null() {
239        return None;
240    }
241    CStr::from_ptr(p).to_str().ok().map(str::to_owned)
242}
243
244/// Null-check `out_ptr` and `out_len` before writing through them.
245/// The helper is callable from any FFI boundary; a future caller
246/// forgetting to check produced UB (write through null). Returns
247/// `NetError::NullPointer` so the FFI caller can distinguish "I
248/// forgot to provide outputs" from "the operation failed."
249fn write_json_out<T: Serialize>(
250    value: &T,
251    out_ptr: *mut *mut c_char,
252    out_len: *mut usize,
253) -> c_int {
254    if out_ptr.is_null() || out_len.is_null() {
255        return NetError::NullPointer.into();
256    }
257    let Ok(s) = serde_json::to_string(value) else {
258        return NetError::Unknown.into();
259    };
260    let len = s.len();
261    let Ok(cs) = CString::new(s) else {
262        return NetError::Unknown.into();
263    };
264    unsafe {
265        *out_ptr = cs.into_raw();
266        *out_len = len;
267    }
268    0
269}
270
271pub(super) fn write_string_out(s: String, out_ptr: *mut *mut c_char, out_len: *mut usize) -> c_int {
272    if out_ptr.is_null() || out_len.is_null() {
273        return NetError::NullPointer.into();
274    }
275    let len = s.len();
276    let Ok(cs) = CString::new(s) else {
277        return NetError::Unknown.into();
278    };
279    unsafe {
280        *out_ptr = cs.into_raw();
281        *out_len = len;
282    }
283    0
284}
285
286fn adapter_err_to_code(err: &AdapterError) -> c_int {
287    match err {
288        AdapterError::Connection(_) => NET_ERR_MESH_HANDSHAKE,
289        _ => NET_ERR_MESH_TRANSPORT,
290    }
291}
292
293fn stream_err_to_code(err: &StreamError) -> c_int {
294    match err {
295        StreamError::Backpressure => NET_ERR_MESH_BACKPRESSURE,
296        StreamError::NotConnected => NET_ERR_MESH_NOT_CONNECTED,
297        StreamError::Transport(_) => NET_ERR_MESH_TRANSPORT,
298    }
299}
300
301// =========================================================================
302// MeshNode
303// =========================================================================
304
305#[derive(Deserialize)]
306struct SubnetPolicyJson {
307    #[serde(default)]
308    rules: Vec<SubnetRuleJson>,
309}
310
311#[derive(Deserialize)]
312struct SubnetRuleJson {
313    tag_prefix: String,
314    level: u32,
315    #[serde(default)]
316    values: std::collections::HashMap<String, u32>,
317}
318
319fn u8_from_u32(value: u32) -> Option<u8> {
320    if value > 255 {
321        None
322    } else {
323        Some(value as u8)
324    }
325}
326
327fn subnet_id_from_json(levels: Vec<u32>) -> Option<SubnetId> {
328    if levels.is_empty() || levels.len() > 4 {
329        return None;
330    }
331    let mut bytes = [0u8; 4];
332    for (i, raw) in levels.iter().enumerate() {
333        bytes[i] = u8_from_u32(*raw)?;
334    }
335    Some(SubnetId::new(&bytes[..levels.len()]))
336}
337
338fn subnet_policy_from_json(p: SubnetPolicyJson) -> Option<SubnetPolicy> {
339    let mut policy = SubnetPolicy::new();
340    for rule_json in p.rules {
341        let level = u8_from_u32(rule_json.level)?;
342        if level > 3 {
343            return None;
344        }
345        let mut rule = SubnetRule::new(rule_json.tag_prefix, level);
346        for (tag_value, raw_val) in rule_json.values {
347            let v = u8_from_u32(raw_val)?;
348            // `SubnetRule::map` panics when `v == 0` — zero is
349            // reserved by the core as "unmatched / no restriction"
350            // and must not appear as an explicit mapping. Reject
351            // at the FFI boundary so Go callers surface a clean
352            // `NET_ERR_MESH_INIT` instead of a cdylib abort.
353            if v == 0 {
354                return None;
355            }
356            rule = rule.map(tag_value, v);
357        }
358        policy = policy.add_rule(rule);
359    }
360    Some(policy)
361}
362
363#[derive(Deserialize)]
364struct MeshNewConfig {
365    bind_addr: String,
366    /// Hex-encoded 32-byte pre-shared key.
367    psk_hex: String,
368    heartbeat_ms: Option<u64>,
369    session_timeout_ms: Option<u64>,
370    num_shards: Option<u16>,
371    /// Capability GC interval (ms). Drives eviction of stale
372    /// capability index entries.
373    capability_gc_interval_ms: Option<u64>,
374    /// Reject unsigned capability announcements when `true`.
375    /// Defaults to the core's default (`false` in v1).
376    require_signed_capabilities: Option<bool>,
377    /// 1–4 bytes, each 0–255. Leave unset for `SubnetId::GLOBAL`.
378    subnet: Option<Vec<u32>>,
379    /// Optional `{"rules": [{"tag_prefix", "level", "values"}]}` policy.
380    subnet_policy: Option<SubnetPolicyJson>,
381    /// Hex-encoded 32-byte ed25519 seed — when present, the mesh
382    /// reproduces the same `entity_id` as
383    /// `IdentityFromSeed(sameSeed)`. Leave unset to generate a fresh
384    /// keypair.
385    identity_seed_hex: Option<String>,
386    /// Pin this mesh's publicly-advertised reflex address (an
387    /// `"ip:port"` string). Classification is skipped; the node
388    /// starts in `nat:open` with this address on its capability
389    /// announcements. Silently ignored when the cdylib is built
390    /// without `--features nat-traversal`.
391    #[serde(default)]
392    reflex_override: Option<String>,
393    /// Opt into opportunistic UPnP / NAT-PMP / PCP port mapping
394    /// at startup. Silently ignored when the cdylib is built
395    /// without `--features port-mapping`.
396    #[serde(default)]
397    try_port_mapping: bool,
398}
399
400/// FFI handle for a [`MeshNode`].
401///
402/// `HandleGuard`-protected: the box stays leaked across `_free`;
403/// ops register via `try_enter` and `_free` quiesces them via
404/// `begin_free`. Without this, an unconditional `Box::from_raw`
405/// would race concurrent `net_mesh_send` (and ~60 other entry
406/// points) into UAF on the dropped Box.
407///
408/// `inner` and `channel_configs` live in `ManuallyDrop` so
409/// `_free` can take them out after the drain. Other Arc clones
410/// held by surviving `MeshStreamHandle._node` keep `MeshNode`
411/// alive until those streams are also freed.
412pub struct MeshNodeHandle {
413    inner: ManuallyDrop<Arc<MeshNode>>,
414    channel_configs: ManuallyDrop<Arc<ChannelConfigRegistry>>,
415    guard: HandleGuard,
416}
417
418/// Create a new mesh node. `config_json` is:
419///
420/// ```json
421/// {
422///   "bind_addr": "127.0.0.1:9000",
423///   "psk_hex":   "42424242...",   // 64 hex chars
424///   "heartbeat_ms": 5000,
425///   "session_timeout_ms": 30000,
426///   "num_shards": 4
427/// }
428/// ```
429///
430/// Installs an empty `ChannelConfigRegistry` at creation time so
431/// `net_mesh_register_channel` can insert without a mutable ref.
432#[unsafe(no_mangle)]
433pub unsafe extern "C" fn net_mesh_new(
434    config_json: *const c_char,
435    out_handle: *mut *mut MeshNodeHandle,
436) -> c_int {
437    if config_json.is_null() || out_handle.is_null() {
438        return NetError::NullPointer.into();
439    }
440    let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
441        return NetError::InvalidUtf8.into();
442    };
443    let cfg: MeshNewConfig = match serde_json::from_str(&s) {
444        Ok(v) => v,
445        Err(_) => return NetError::InvalidJson.into(),
446    };
447    let bind_addr: std::net::SocketAddr = match cfg.bind_addr.parse() {
448        Ok(a) => a,
449        Err(_) => return NET_ERR_MESH_INIT,
450    };
451    let psk_bytes = match hex::decode(&cfg.psk_hex) {
452        Ok(b) => b,
453        Err(_) => return NET_ERR_MESH_INIT,
454    };
455    if psk_bytes.len() != 32 {
456        return NET_ERR_MESH_INIT;
457    }
458    let mut psk = [0u8; 32];
459    psk.copy_from_slice(&psk_bytes);
460
461    let mut node_cfg = MeshNodeConfig::new(bind_addr, psk);
462    // Reject `0` for `heartbeat_ms` and `session_timeout_ms`.
463    // A zero heartbeat interval busy-loops the heartbeat task
464    // (saturating a CPU); a zero session timeout makes every
465    // session expire instantly. The Rust-side configs do their
466    // own validation but the FFI JSON path bypasses that — pin
467    // the guard here so a misconfig fails fast rather than
468    // producing a hung daemon.
469    if let Some(ms) = cfg.heartbeat_ms {
470        if ms == 0 {
471            return NetError::InvalidJson.into();
472        }
473        node_cfg = node_cfg.with_heartbeat_interval(std::time::Duration::from_millis(ms));
474    }
475    if let Some(ms) = cfg.session_timeout_ms {
476        if ms == 0 {
477            return NetError::InvalidJson.into();
478        }
479        node_cfg = node_cfg.with_session_timeout(std::time::Duration::from_millis(ms));
480    }
481    if let Some(n) = cfg.num_shards {
482        node_cfg = node_cfg.with_num_shards(n);
483    }
484    if let Some(ms) = cfg.capability_gc_interval_ms {
485        node_cfg = node_cfg.with_capability_gc_interval(std::time::Duration::from_millis(ms));
486    }
487    if let Some(b) = cfg.require_signed_capabilities {
488        node_cfg = node_cfg.with_require_signed_capabilities(b);
489    }
490    if let Some(levels) = cfg.subnet {
491        let Some(id) = subnet_id_from_json(levels) else {
492            return NET_ERR_MESH_INIT;
493        };
494        node_cfg = node_cfg.with_subnet(id);
495    }
496    if let Some(policy_js) = cfg.subnet_policy {
497        let Some(policy) = subnet_policy_from_json(policy_js) else {
498            return NET_ERR_MESH_INIT;
499        };
500        node_cfg = node_cfg.with_subnet_policy(Arc::new(policy));
501    }
502    #[cfg(feature = "nat-traversal")]
503    if let Some(external_str) = cfg.reflex_override.as_deref() {
504        let Ok(external) = external_str.parse::<std::net::SocketAddr>() else {
505            return NET_ERR_MESH_INIT;
506        };
507        node_cfg = node_cfg.with_reflex_override(external);
508    }
509    // Silently drop the field in builds without nat-traversal so
510    // Go callers compiled against a full-feature cdylib can fall
511    // back to a thin cdylib without a JSON-parse error.
512    #[cfg(not(feature = "nat-traversal"))]
513    let _ = cfg.reflex_override;
514    #[cfg(feature = "port-mapping")]
515    if cfg.try_port_mapping {
516        node_cfg = node_cfg.with_try_port_mapping(true);
517    }
518    // Same drop-on-the-floor pattern as reflex_override above.
519    #[cfg(not(feature = "port-mapping"))]
520    let _ = cfg.try_port_mapping;
521
522    let identity = match cfg.identity_seed_hex {
523        Some(seed_hex) => {
524            let bytes = match hex::decode(&seed_hex) {
525                Ok(b) => b,
526                Err(_) => return NET_ERR_MESH_INIT,
527            };
528            if bytes.len() != 32 {
529                return NET_ERR_MESH_INIT;
530            }
531            let mut arr = [0u8; 32];
532            arr.copy_from_slice(&bytes);
533            EntityKeypair::from_bytes(arr)
534        }
535        None => EntityKeypair::generate(),
536    };
537    let result = block_on(async move { MeshNode::new(identity, node_cfg).await });
538    match result {
539        Ok(mut node) => {
540            let channel_configs = Arc::new(ChannelConfigRegistry::new());
541            node.set_channel_configs(channel_configs.clone());
542            // Install a fresh TokenCache — channel auth needs
543            // somewhere to stash tokens presented on subscribe.
544            // Matches the PyO3 / NAPI behaviour.
545            node.set_token_cache(Arc::new(TokenCache::new()));
546            let handle = Box::new(MeshNodeHandle {
547                inner: ManuallyDrop::new(Arc::new(node)),
548                channel_configs: ManuallyDrop::new(channel_configs),
549                guard: HandleGuard::new(),
550            });
551            unsafe {
552                *out_handle = Box::into_raw(handle);
553            }
554            0
555        }
556        Err(_) => NET_ERR_MESH_INIT,
557    }
558}
559
560#[unsafe(no_mangle)]
561pub unsafe extern "C" fn net_mesh_free(handle: *mut MeshNodeHandle) {
562    if handle.is_null() {
563        return;
564    }
565    // Quiesce in-flight ops before dropping the inner. Box stays
566    // leaked. Other Arc clones held by surviving
567    // MeshStreamHandle._node keep MeshNode alive until their own
568    // _free runs.
569    let h: &MeshNodeHandle = unsafe { &*handle };
570    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
571        // SAFETY: drained; sole writable reference.
572        unsafe {
573            let mh = &mut *handle;
574            let inner = ManuallyDrop::take(&mut mh.inner);
575            let configs = ManuallyDrop::take(&mut mh.channel_configs);
576            drop(inner);
577            drop(configs);
578        }
579    } else {
580        tracing::warn!(
581            "net_mesh_free: in-flight ops did not drain within deadline; \
582             leaking inner to avoid use-after-free"
583        );
584    }
585}
586
587/// Clone the `Arc<MeshNode>` backing this handle and return a
588/// `*mut Arc<MeshNode>`. Used by the compute-FFI crate so the
589/// Go binding's `DaemonRuntime` can share the live mesh node
590/// without opening a second socket.
591///
592/// Caller takes ownership of the returned pointer and MUST free it
593/// with [`net_mesh_arc_free`]. Returns NULL if `handle` is NULL.
594#[unsafe(no_mangle)]
595pub unsafe extern "C" fn net_mesh_arc_clone(handle: *mut MeshNodeHandle) -> *mut Arc<MeshNode> {
596    if handle.is_null() {
597        return std::ptr::null_mut();
598    }
599    let h = unsafe { &*handle };
600    // Returns NULL on shutting-down — same shape as absent-handle.
601    let _op = match h.guard.try_enter() {
602        Some(op) => op,
603        None => return std::ptr::null_mut(),
604    };
605    let cloned: Arc<MeshNode> = Arc::clone(&h.inner);
606    Box::into_raw(Box::new(cloned))
607}
608
609/// Clone the shared `Arc<ChannelConfigRegistry>` backing this
610/// handle. Used by compute-FFI so migration-triggered channel
611/// rebind replays hit the same registry the mesh publishes to.
612///
613/// Caller takes ownership and MUST free with
614/// [`net_mesh_channel_configs_arc_free`].
615#[unsafe(no_mangle)]
616pub unsafe extern "C" fn net_mesh_channel_configs_arc_clone(
617    handle: *mut MeshNodeHandle,
618) -> *mut Arc<ChannelConfigRegistry> {
619    if handle.is_null() {
620        return std::ptr::null_mut();
621    }
622    let h = unsafe { &*handle };
623    // Returns NULL on shutting-down — same shape as absent-handle.
624    let _op = match h.guard.try_enter() {
625        Some(op) => op,
626        None => return std::ptr::null_mut(),
627    };
628    let cloned: Arc<ChannelConfigRegistry> = Arc::clone(&h.channel_configs);
629    Box::into_raw(Box::new(cloned))
630}
631
632/// Free an `Arc<MeshNode>` handle produced by
633/// [`net_mesh_arc_clone`]. Idempotent on NULL.
634#[unsafe(no_mangle)]
635pub unsafe extern "C" fn net_mesh_arc_free(p: *mut Arc<MeshNode>) {
636    if p.is_null() {
637        return;
638    }
639    unsafe {
640        drop(Box::from_raw(p));
641    }
642}
643
644/// Free an `Arc<ChannelConfigRegistry>` handle produced by
645/// [`net_mesh_channel_configs_arc_clone`]. Idempotent on NULL.
646#[unsafe(no_mangle)]
647pub unsafe extern "C" fn net_mesh_channel_configs_arc_free(p: *mut Arc<ChannelConfigRegistry>) {
648    if p.is_null() {
649        return;
650    }
651    unsafe {
652        drop(Box::from_raw(p));
653    }
654}
655
656/// Write the hex-encoded 32-byte Noise static public key of this
657/// node to `*out`. Caller frees via `net_free_string`.
658#[unsafe(no_mangle)]
659pub unsafe extern "C" fn net_mesh_public_key_hex(
660    handle: *mut MeshNodeHandle,
661    out_ptr: *mut *mut c_char,
662    out_len: *mut usize,
663) -> c_int {
664    if handle.is_null() || out_ptr.is_null() || out_len.is_null() {
665        return NetError::NullPointer.into();
666    }
667    let h = unsafe { &*handle };
668    let _op = match h.guard.try_enter() {
669        Some(op) => op,
670        None => return NetError::ShuttingDown.into(),
671    };
672    let s = hex::encode(h.inner.public_key());
673    write_string_out(s, out_ptr, out_len)
674}
675
676#[unsafe(no_mangle)]
677pub unsafe extern "C" fn net_mesh_node_id(handle: *mut MeshNodeHandle) -> u64 {
678    if handle.is_null() {
679        return 0;
680    }
681    let h = unsafe { &*handle };
682    // Returns 0 on shutting-down — same shape as absent-handle.
683    let _op = match h.guard.try_enter() {
684        Some(op) => op,
685        None => return 0,
686    };
687    h.inner.node_id()
688}
689
690/// Writes the 32-byte ed25519 entity id of this mesh into `out[32]`.
691/// Matches `Identity::from_seed(seed).entity_id` when the mesh was
692/// constructed with `identity_seed_hex = hex::encode(seed)`.
693#[unsafe(no_mangle)]
694pub unsafe extern "C" fn net_mesh_entity_id(handle: *mut MeshNodeHandle, out: *mut u8) -> c_int {
695    if handle.is_null() || out.is_null() {
696        return NetError::NullPointer.into();
697    }
698    let h = unsafe { &*handle };
699    let _op = match h.guard.try_enter() {
700        Some(op) => op,
701        None => return NetError::ShuttingDown.into(),
702    };
703    let bytes = h.inner.entity_id().as_bytes();
704    unsafe {
705        std::ptr::copy_nonoverlapping(bytes.as_ptr(), out, 32);
706    }
707    0
708}
709
710/// Connect (initiator). Blocks until the handshake completes.
711#[unsafe(no_mangle)]
712pub unsafe extern "C" fn net_mesh_connect(
713    handle: *mut MeshNodeHandle,
714    peer_addr: *const c_char,
715    peer_pubkey_hex: *const c_char,
716    peer_node_id: u64,
717) -> c_int {
718    if handle.is_null() || peer_addr.is_null() || peer_pubkey_hex.is_null() {
719        return NetError::NullPointer.into();
720    }
721    let h = unsafe { &*handle };
722    let _op = match h.guard.try_enter() {
723        Some(op) => op,
724        None => return NetError::ShuttingDown.into(),
725    };
726    let Some(addr_s) = (unsafe { c_str_to_string(peer_addr) }) else {
727        return NetError::InvalidUtf8.into();
728    };
729    let addr: std::net::SocketAddr = match addr_s.parse() {
730        Ok(a) => a,
731        Err(_) => return NET_ERR_MESH_HANDSHAKE,
732    };
733    let Some(pk_s) = (unsafe { c_str_to_string(peer_pubkey_hex) }) else {
734        return NetError::InvalidUtf8.into();
735    };
736    let pk_bytes = match hex::decode(pk_s) {
737        Ok(b) => b,
738        Err(_) => return NET_ERR_MESH_HANDSHAKE,
739    };
740    if pk_bytes.len() != 32 {
741        return NET_ERR_MESH_HANDSHAKE;
742    }
743    let mut pk = [0u8; 32];
744    pk.copy_from_slice(&pk_bytes);
745
746    let node = h.inner.clone();
747    match block_on(async move { node.connect(addr, &pk, peer_node_id).await }) {
748        Ok(_) => 0,
749        Err(e) => adapter_err_to_code(&e),
750    }
751}
752
753/// Accept an incoming connection (responder). Writes the peer's wire
754/// address to `*out_addr` (caller frees via `net_free_string`).
755#[unsafe(no_mangle)]
756pub unsafe extern "C" fn net_mesh_accept(
757    handle: *mut MeshNodeHandle,
758    peer_node_id: u64,
759    out_addr: *mut *mut c_char,
760    out_len: *mut usize,
761) -> c_int {
762    if handle.is_null() || out_addr.is_null() || out_len.is_null() {
763        return NetError::NullPointer.into();
764    }
765    let h = unsafe { &*handle };
766    let _op = match h.guard.try_enter() {
767        Some(op) => op,
768        None => return NetError::ShuttingDown.into(),
769    };
770    let node = h.inner.clone();
771    match block_on(async move { node.accept(peer_node_id).await }) {
772        Ok((addr, _)) => write_string_out(addr.to_string(), out_addr, out_len),
773        Err(e) => adapter_err_to_code(&e),
774    }
775}
776
777#[unsafe(no_mangle)]
778pub unsafe extern "C" fn net_mesh_start(handle: *mut MeshNodeHandle) -> c_int {
779    if handle.is_null() {
780        return NetError::NullPointer.into();
781    }
782    let h = unsafe { &*handle };
783    let _op = match h.guard.try_enter() {
784        Some(op) => op,
785        None => return NetError::ShuttingDown.into(),
786    };
787    let node = h.inner.clone();
788    // `start` spawns internal tasks via tokio::spawn; run under the
789    // shared runtime.
790    block_on(async move { node.start() });
791    0
792}
793
794/// Shut down the node. Must be called before `net_mesh_free` to
795/// release network resources. Idempotent.
796///
797/// Runs unconditionally — `MeshNode::shutdown` takes `&self` and
798/// the underlying primitives (shutdown flag, notify, deactivate)
799/// are safe to call while other handles still hold the `Arc`. A
800/// prior version silently returned 0 whenever `Arc::strong_count`
801/// exceeded 1, which meant a caller that held a stream handle
802/// would see "shutdown successful" without any tasks actually
803/// stopping — the node kept running until every stream was
804/// dropped. Callers now always get the real shutdown outcome.
805#[unsafe(no_mangle)]
806pub unsafe extern "C" fn net_mesh_shutdown(handle: *mut MeshNodeHandle) -> c_int {
807    if handle.is_null() {
808        return NetError::NullPointer.into();
809    }
810    let h = unsafe { &*handle };
811    let _op = match h.guard.try_enter() {
812        Some(op) => op,
813        None => return NetError::ShuttingDown.into(),
814    };
815    match block_on(async { h.inner.shutdown().await }) {
816        Ok(()) => 0,
817        Err(e) => adapter_err_to_code(&e),
818    }
819}
820
821// =========================================================================
822// NAT traversal
823// =========================================================================
824//
825// Framing (plan §5, load-bearing): every user-visible docstring
826// positions NAT traversal as **optimization, not correctness**.
827// Nodes behind NAT can always reach each other through the
828// routed-handshake path. A `nat_type` of `"symmetric"` or any
829// `NET_ERR_TRAVERSAL_*` code is not a connectivity failure —
830// traffic keeps riding the relay. Each function returns early
831// with `NetError::Unsupported` (= -1 NetError variant) when the
832// crate is built without `nat-traversal`, so cgo call sites that
833// unconditionally reference these symbols still link.
834
835/// Write this mesh's NAT classification into `out_str` as one of
836/// `"open" | "cone" | "symmetric" | "unknown"`. Stable vocabulary
837/// — matches the NAPI / PyO3 binding strings. Caller frees via
838/// `net_free_string`.
839///
840/// Returns `0` on success or a NetError code on failure. Only
841/// present when the crate is built with `--features nat-traversal`.
842#[cfg(feature = "nat-traversal")]
843#[unsafe(no_mangle)]
844pub unsafe extern "C" fn net_mesh_nat_type(
845    handle: *mut MeshNodeHandle,
846    out_str: *mut *mut c_char,
847    out_len: *mut usize,
848) -> c_int {
849    if handle.is_null() || out_str.is_null() || out_len.is_null() {
850        return NetError::NullPointer.into();
851    }
852    let h = unsafe { &*handle };
853    let _op = match h.guard.try_enter() {
854        Some(op) => op,
855        None => return NetError::ShuttingDown.into(),
856    };
857    write_string_out(
858        nat_class_to_str(h.inner.nat_class()).to_string(),
859        out_str,
860        out_len,
861    )
862}
863
864/// Write this mesh's last-observed reflex `ip:port` into
865/// `out_str`. When no reflex has been observed yet (pre-
866/// classification, or only one peer connected), writes an empty
867/// string and still returns `0`.
868#[cfg(feature = "nat-traversal")]
869#[unsafe(no_mangle)]
870pub unsafe extern "C" fn net_mesh_reflex_addr(
871    handle: *mut MeshNodeHandle,
872    out_str: *mut *mut c_char,
873    out_len: *mut usize,
874) -> c_int {
875    if handle.is_null() || out_str.is_null() || out_len.is_null() {
876        return NetError::NullPointer.into();
877    }
878    let h = unsafe { &*handle };
879    let _op = match h.guard.try_enter() {
880        Some(op) => op,
881        None => return NetError::ShuttingDown.into(),
882    };
883    let s = h
884        .inner
885        .reflex_addr()
886        .map(|a| a.to_string())
887        .unwrap_or_default();
888    write_string_out(s, out_str, out_len)
889}
890
891/// Write `peer_node_id`'s advertised NAT classification (read
892/// from its `nat:*` capability tag) into `out_str`. Returns
893/// `"unknown"` when we have no announcement from that peer.
894#[cfg(feature = "nat-traversal")]
895#[unsafe(no_mangle)]
896pub unsafe extern "C" fn net_mesh_peer_nat_type(
897    handle: *mut MeshNodeHandle,
898    peer_node_id: u64,
899    out_str: *mut *mut c_char,
900    out_len: *mut usize,
901) -> c_int {
902    if handle.is_null() || out_str.is_null() || out_len.is_null() {
903        return NetError::NullPointer.into();
904    }
905    let h = unsafe { &*handle };
906    let _op = match h.guard.try_enter() {
907        Some(op) => op,
908        None => return NetError::ShuttingDown.into(),
909    };
910    write_string_out(
911        nat_class_to_str(h.inner.peer_nat_class(peer_node_id)).to_string(),
912        out_str,
913        out_len,
914    )
915}
916
917/// Send one reflex probe to `peer_node_id` and write the public
918/// `ip:port` the peer observed into `out_str`. Blocks on the
919/// shared runtime until the probe completes or times out.
920///
921/// Returns `0` on success or a `NET_ERR_TRAVERSAL_*` code on
922/// failure. `NET_ERR_TRAVERSAL_REFLEX_TIMEOUT` means the probe
923/// didn't complete in time; `NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE`
924/// means we have no session with `peer_node_id`.
925#[cfg(feature = "nat-traversal")]
926#[unsafe(no_mangle)]
927pub unsafe extern "C" fn net_mesh_probe_reflex(
928    handle: *mut MeshNodeHandle,
929    peer_node_id: u64,
930    out_str: *mut *mut c_char,
931    out_len: *mut usize,
932) -> c_int {
933    if handle.is_null() || out_str.is_null() || out_len.is_null() {
934        return NetError::NullPointer.into();
935    }
936    let h = unsafe { &*handle };
937    let _op = match h.guard.try_enter() {
938        Some(op) => op,
939        None => return NetError::ShuttingDown.into(),
940    };
941    let node = h.inner.clone();
942    match block_on(async move { node.probe_reflex(peer_node_id).await }) {
943        Ok(addr) => write_string_out(addr.to_string(), out_str, out_len),
944        Err(e) => traversal_err_to_code(&e),
945    }
946}
947
948/// Explicitly re-run the NAT classification sweep. No-op when
949/// fewer than 2 peers are connected. Never returns an error;
950/// callers that want the result should read `nat_type` +
951/// `reflex_addr` afterward.
952#[cfg(feature = "nat-traversal")]
953#[unsafe(no_mangle)]
954pub unsafe extern "C" fn net_mesh_reclassify_nat(handle: *mut MeshNodeHandle) -> c_int {
955    if handle.is_null() {
956        return NetError::NullPointer.into();
957    }
958    let h = unsafe { &*handle };
959    let _op = match h.guard.try_enter() {
960        Some(op) => op,
961        None => return NetError::ShuttingDown.into(),
962    };
963    let node = h.inner.clone();
964    block_on(async move { node.reclassify_nat().await });
965    0
966}
967
968/// Fill `out_punches_attempted`, `out_punches_succeeded`,
969/// `out_relay_fallbacks` with the current cumulative counters.
970/// Each pointer may be null to skip that field. Monotonic —
971/// counters never decrease or reset.
972#[cfg(feature = "nat-traversal")]
973#[unsafe(no_mangle)]
974pub unsafe extern "C" fn net_mesh_traversal_stats(
975    handle: *mut MeshNodeHandle,
976    out_punches_attempted: *mut u64,
977    out_punches_succeeded: *mut u64,
978    out_relay_fallbacks: *mut u64,
979) -> c_int {
980    if handle.is_null() {
981        return NetError::NullPointer.into();
982    }
983    let h = unsafe { &*handle };
984    let _op = match h.guard.try_enter() {
985        Some(op) => op,
986        None => return NetError::ShuttingDown.into(),
987    };
988    let snap = h.inner.traversal_stats();
989    unsafe {
990        if !out_punches_attempted.is_null() {
991            *out_punches_attempted = snap.punches_attempted;
992        }
993        if !out_punches_succeeded.is_null() {
994            *out_punches_succeeded = snap.punches_succeeded;
995        }
996        if !out_relay_fallbacks.is_null() {
997            *out_relay_fallbacks = snap.relay_fallbacks;
998        }
999    }
1000    0
1001}
1002
1003/// Establish a session to `peer_node_id` via rendezvous through
1004/// `coordinator`, picking between direct-handshake and a
1005/// coordinated punch per the pair-type matrix. Always resolves
1006/// (on punch-failed, falls back to routed). Inspect the stats
1007/// counters afterward to distinguish outcomes.
1008///
1009/// `peer_pubkey_hex` is the peer's 32-byte Noise static public
1010/// key as a 64-char hex string.
1011///
1012/// Returns `0` on success or a `NET_ERR_TRAVERSAL_*` /
1013/// `NET_ERR_MESH_HANDSHAKE` code on failure.
1014#[cfg(feature = "nat-traversal")]
1015#[unsafe(no_mangle)]
1016pub unsafe extern "C" fn net_mesh_connect_direct(
1017    handle: *mut MeshNodeHandle,
1018    peer_node_id: u64,
1019    peer_pubkey_hex: *const c_char,
1020    coordinator: u64,
1021) -> c_int {
1022    if handle.is_null() || peer_pubkey_hex.is_null() {
1023        return NetError::NullPointer.into();
1024    }
1025    let h = unsafe { &*handle };
1026    let _op = match h.guard.try_enter() {
1027        Some(op) => op,
1028        None => return NetError::ShuttingDown.into(),
1029    };
1030    let Some(pk_s) = (unsafe { c_str_to_string(peer_pubkey_hex) }) else {
1031        return NetError::InvalidUtf8.into();
1032    };
1033    let pk_bytes = match hex::decode(pk_s) {
1034        Ok(b) => b,
1035        Err(_) => return NET_ERR_MESH_HANDSHAKE,
1036    };
1037    if pk_bytes.len() != 32 {
1038        return NET_ERR_MESH_HANDSHAKE;
1039    }
1040    let mut pk = [0u8; 32];
1041    pk.copy_from_slice(&pk_bytes);
1042
1043    let node = h.inner.clone();
1044    match block_on(async move { node.connect_direct(peer_node_id, &pk, coordinator).await }) {
1045        Ok(_) => 0,
1046        Err(e) => traversal_err_to_code(&e),
1047    }
1048}
1049
1050/// Install a runtime reflex override. `external` is a
1051/// UTF-8 / null-terminated `"ip:port"` string. Forces `nat_type`
1052/// to `"open"` and `reflex_addr` to `external` immediately;
1053/// short-circuits any further classifier sweeps.
1054///
1055/// Returns `0` on success or `NET_ERR_MESH_INIT` on a malformed
1056/// address.
1057#[cfg(feature = "nat-traversal")]
1058#[unsafe(no_mangle)]
1059pub unsafe extern "C" fn net_mesh_set_reflex_override(
1060    handle: *mut MeshNodeHandle,
1061    external: *const c_char,
1062) -> c_int {
1063    if handle.is_null() || external.is_null() {
1064        return NetError::NullPointer.into();
1065    }
1066    let h = unsafe { &*handle };
1067    let _op = match h.guard.try_enter() {
1068        Some(op) => op,
1069        None => return NetError::ShuttingDown.into(),
1070    };
1071    let Some(s) = (unsafe { c_str_to_string(external) }) else {
1072        return NetError::InvalidUtf8.into();
1073    };
1074    let Ok(addr) = s.parse::<std::net::SocketAddr>() else {
1075        return NET_ERR_MESH_INIT;
1076    };
1077    h.inner.set_reflex_override(addr);
1078    0
1079}
1080
1081/// Drop a previously-installed reflex override. The classifier
1082/// resumes on its normal cadence; `reflex_addr` clears to empty
1083/// immediately so a between-sweep read doesn't return a stale
1084/// override.
1085///
1086/// No-op when no override is active. Always returns `0` on a
1087/// live handle.
1088#[cfg(feature = "nat-traversal")]
1089#[unsafe(no_mangle)]
1090pub unsafe extern "C" fn net_mesh_clear_reflex_override(handle: *mut MeshNodeHandle) -> c_int {
1091    if handle.is_null() {
1092        return NetError::NullPointer.into();
1093    }
1094    let h = unsafe { &*handle };
1095    let _op = match h.guard.try_enter() {
1096        Some(op) => op,
1097        None => return NetError::ShuttingDown.into(),
1098    };
1099    h.inner.clear_reflex_override();
1100    0
1101}
1102
1103// =========================================================================
1104// NAT-traversal fallback stubs — built when the core is
1105// compiled *without* `--features nat-traversal`.
1106//
1107// Bug L (cubic, P1): the Go / NAPI / PyO3 bindings unconditionally
1108// link against these symbols, so a cdylib without the feature
1109// used to fail at dlopen / load time with missing-symbol
1110// errors. The doc comment on each binding promised
1111// `ErrTraversalUnsupported` as the runtime surface for a no-
1112// feature build, but there were no stubs to back that promise.
1113//
1114// These stubs make the promise real: the symbol resolves, the
1115// call returns `NET_ERR_TRAVERSAL_UNSUPPORTED`, and the Go
1116// error-mapping layer translates that to
1117// `ErrTraversalUnsupported`. No heap allocation — the `_out_*`
1118// pointers are left untouched (the Go side treats them as
1119// invalid on a nonzero return).
1120//
1121// Every signature mirrors the `#[cfg(feature = "nat-traversal")]`
1122// definition above. Ordering matches the feature-on block so
1123// diff review can line up the pair at a glance.
1124
1125#[cfg(not(feature = "nat-traversal"))]
1126#[unsafe(no_mangle)]
1127pub unsafe extern "C" fn net_mesh_nat_type(
1128    _handle: *mut MeshNodeHandle,
1129    _out_str: *mut *mut c_char,
1130    _out_len: *mut usize,
1131) -> c_int {
1132    NET_ERR_TRAVERSAL_UNSUPPORTED
1133}
1134
1135#[cfg(not(feature = "nat-traversal"))]
1136#[unsafe(no_mangle)]
1137pub unsafe extern "C" fn net_mesh_reflex_addr(
1138    _handle: *mut MeshNodeHandle,
1139    _out_str: *mut *mut c_char,
1140    _out_len: *mut usize,
1141) -> c_int {
1142    NET_ERR_TRAVERSAL_UNSUPPORTED
1143}
1144
1145#[cfg(not(feature = "nat-traversal"))]
1146#[unsafe(no_mangle)]
1147pub unsafe extern "C" fn net_mesh_peer_nat_type(
1148    _handle: *mut MeshNodeHandle,
1149    _peer_node_id: u64,
1150    _out_str: *mut *mut c_char,
1151    _out_len: *mut usize,
1152) -> c_int {
1153    NET_ERR_TRAVERSAL_UNSUPPORTED
1154}
1155
1156#[cfg(not(feature = "nat-traversal"))]
1157#[unsafe(no_mangle)]
1158pub unsafe extern "C" fn net_mesh_probe_reflex(
1159    _handle: *mut MeshNodeHandle,
1160    _peer_node_id: u64,
1161    _out_str: *mut *mut c_char,
1162    _out_len: *mut usize,
1163) -> c_int {
1164    NET_ERR_TRAVERSAL_UNSUPPORTED
1165}
1166
1167#[cfg(not(feature = "nat-traversal"))]
1168#[unsafe(no_mangle)]
1169pub unsafe extern "C" fn net_mesh_reclassify_nat(_handle: *mut MeshNodeHandle) -> c_int {
1170    NET_ERR_TRAVERSAL_UNSUPPORTED
1171}
1172
1173#[cfg(not(feature = "nat-traversal"))]
1174#[unsafe(no_mangle)]
1175pub unsafe extern "C" fn net_mesh_traversal_stats(
1176    _handle: *mut MeshNodeHandle,
1177    _out_punches_attempted: *mut u64,
1178    _out_punches_succeeded: *mut u64,
1179    _out_relay_fallbacks: *mut u64,
1180) -> c_int {
1181    NET_ERR_TRAVERSAL_UNSUPPORTED
1182}
1183
1184#[cfg(not(feature = "nat-traversal"))]
1185#[unsafe(no_mangle)]
1186pub unsafe extern "C" fn net_mesh_connect_direct(
1187    _handle: *mut MeshNodeHandle,
1188    _peer_node_id: u64,
1189    _peer_pubkey_hex: *const c_char,
1190    _coordinator: u64,
1191) -> c_int {
1192    NET_ERR_TRAVERSAL_UNSUPPORTED
1193}
1194
1195#[cfg(not(feature = "nat-traversal"))]
1196#[unsafe(no_mangle)]
1197pub unsafe extern "C" fn net_mesh_set_reflex_override(
1198    _handle: *mut MeshNodeHandle,
1199    _external: *const c_char,
1200) -> c_int {
1201    NET_ERR_TRAVERSAL_UNSUPPORTED
1202}
1203
1204#[cfg(not(feature = "nat-traversal"))]
1205#[unsafe(no_mangle)]
1206pub unsafe extern "C" fn net_mesh_clear_reflex_override(_handle: *mut MeshNodeHandle) -> c_int {
1207    NET_ERR_TRAVERSAL_UNSUPPORTED
1208}
1209
1210// =========================================================================
1211// Streams
1212// =========================================================================
1213
1214#[derive(Deserialize, Default)]
1215struct StreamOpenConfig {
1216    /// `"reliable" | "fire_and_forget"`. Default `"fire_and_forget"`.
1217    reliability: Option<String>,
1218    /// Initial send-credit window in bytes. 0 disables backpressure.
1219    /// Default: `DEFAULT_STREAM_WINDOW_BYTES` (64 KB).
1220    window_bytes: Option<u32>,
1221    fairness_weight: Option<u8>,
1222}
1223
1224/// FFI handle for an open stream against a [`MeshNode`].
1225///
1226/// `HandleGuard`-protected. Without it, two distinct UAFs can
1227/// fire: `_node: Arc<MeshNode>` keeps the underlying node alive
1228/// but **not** the `MeshStreamHandle` Box itself —
1229/// `net_mesh_free(node_handle)` could deallocate the node
1230/// handle's box while `net_mesh_send` was deref'ing
1231/// `&*node_handle` for the `Arc::ptr_eq` check in
1232/// `handles_match`. The same hazard applies to this stream
1233/// handle's own box: a concurrent `net_mesh_stream_free` while
1234/// `net_mesh_send` was reading `sh.stream` / `sh._node` would
1235/// UAF the dropped fields. The guard closes both: the box stays
1236/// leaked across `_free`; ops register via `try_enter` and
1237/// `_free` quiesces them via `begin_free`.
1238pub struct MeshStreamHandle {
1239    stream: ManuallyDrop<CoreStream>,
1240    // Keep the node alive as long as the stream is alive so sends
1241    // don't race a concurrent shutdown.
1242    _node: ManuallyDrop<Arc<MeshNode>>,
1243    guard: HandleGuard,
1244}
1245
1246#[unsafe(no_mangle)]
1247pub unsafe extern "C" fn net_mesh_open_stream(
1248    handle: *mut MeshNodeHandle,
1249    peer_node_id: u64,
1250    stream_id: u64,
1251    config_json: *const c_char,
1252    out_stream: *mut *mut MeshStreamHandle,
1253) -> c_int {
1254    if handle.is_null() || out_stream.is_null() {
1255        return NetError::NullPointer.into();
1256    }
1257    let h = unsafe { &*handle };
1258    let _op = match h.guard.try_enter() {
1259        Some(op) => op,
1260        None => return NetError::ShuttingDown.into(),
1261    };
1262    let cfg_json: StreamOpenConfig = if config_json.is_null() {
1263        StreamOpenConfig::default()
1264    } else {
1265        let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
1266            return NetError::InvalidUtf8.into();
1267        };
1268        match serde_json::from_str(&s) {
1269            Ok(v) => v,
1270            Err(_) => return NetError::InvalidJson.into(),
1271        }
1272    };
1273    let reliability = match cfg_json.reliability.as_deref() {
1274        None | Some("fire_and_forget") => Reliability::FireAndForget,
1275        Some("reliable") => Reliability::Reliable,
1276        Some(_) => return NET_ERR_MESH_TRANSPORT,
1277    };
1278    let window = cfg_json.window_bytes.unwrap_or(DEFAULT_STREAM_WINDOW_BYTES);
1279    let weight = cfg_json.fairness_weight.unwrap_or(1);
1280    let cfg = StreamConfig::new()
1281        .with_reliability(reliability)
1282        .with_window_bytes(window)
1283        .with_fairness_weight(weight);
1284    match h.inner.open_stream(peer_node_id, stream_id, cfg) {
1285        Ok(stream) => {
1286            let node_clone: Arc<MeshNode> = Arc::clone(&h.inner);
1287            let sh = Box::new(MeshStreamHandle {
1288                stream: ManuallyDrop::new(stream),
1289                _node: ManuallyDrop::new(node_clone),
1290                guard: HandleGuard::new(),
1291            });
1292            unsafe {
1293                *out_stream = Box::into_raw(sh);
1294            }
1295            0
1296        }
1297        Err(e) => adapter_err_to_code(&e),
1298    }
1299}
1300
1301#[unsafe(no_mangle)]
1302pub unsafe extern "C" fn net_mesh_stream_free(handle: *mut MeshStreamHandle) {
1303    if handle.is_null() {
1304        return;
1305    }
1306    // Quiesce in-flight ops before dropping the inner. Box stays leaked.
1307    let h: &MeshStreamHandle = unsafe { &*handle };
1308    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
1309        // SAFETY: drained; sole writable reference.
1310        unsafe {
1311            // CoreStream is Copy/non-Drop; just take it out and let
1312            // it fall out of scope. The Arc<MeshNode> needs explicit
1313            // drop() to release its refcount.
1314            let _stream = ManuallyDrop::take(&mut (*handle).stream);
1315            let node = ManuallyDrop::take(&mut (*handle)._node);
1316            drop(node);
1317        }
1318    } else {
1319        tracing::warn!(
1320            "net_mesh_stream_free: in-flight ops did not drain within deadline; \
1321             leaking inner to avoid use-after-free"
1322        );
1323    }
1324}
1325
1326/// Collect an array of borrowed `(ptr, len)` pairs into a
1327/// `Vec<Bytes>`. Caller must keep the pointer / length arrays alive
1328/// for the duration of the C call.
1329///
1330/// Returns `None` if any per-entry pointer is null *with* a non-zero
1331/// length — the C contract has no "skip this entry" channel, so the
1332/// only correct response is to refuse the whole batch. A null pointer
1333/// with `len == 0` is treated as an empty payload (it never gets
1334/// dereferenced).
1335unsafe fn collect_payloads(
1336    payloads: *const *const u8,
1337    lens: *const usize,
1338    count: usize,
1339) -> Option<Vec<Bytes>> {
1340    let mut out = Vec::with_capacity(count);
1341    for i in 0..count {
1342        let ptr = *payloads.add(i);
1343        let len = *lens.add(i);
1344        if ptr.is_null() {
1345            if len == 0 {
1346                out.push(Bytes::new());
1347                continue;
1348            }
1349            return None;
1350        }
1351        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1352        // A caller passing a sign-extended `-1` would otherwise
1353        // immediately UB before any other validation runs.
1354        if len > isize::MAX as usize {
1355            return None;
1356        }
1357        let slice = std::slice::from_raw_parts(ptr, len);
1358        out.push(Bytes::copy_from_slice(slice));
1359    }
1360    Some(out)
1361}
1362
1363/// Ensure the supplied stream handle was created by the supplied
1364/// node handle. Without this check, `net_mesh_send` would happily
1365/// route bytes through whichever `MeshNode` was passed, even if the
1366/// stream belonged to a different one — silent cross-session
1367/// traffic. `Arc::ptr_eq` is O(1) and definitive: stream handles
1368/// cache the originating
1369/// node Arc in `_node` for exactly this purpose.
1370#[inline]
1371fn handles_match(sh: &MeshStreamHandle, nh: &MeshNodeHandle) -> bool {
1372    Arc::ptr_eq(&sh._node, &nh.inner)
1373}
1374
1375#[unsafe(no_mangle)]
1376pub unsafe extern "C" fn net_mesh_send(
1377    handle: *mut MeshStreamHandle,
1378    payloads: *const *const u8,
1379    lens: *const usize,
1380    count: usize,
1381    node_handle: *mut MeshNodeHandle,
1382) -> c_int {
1383    if handle.is_null() || node_handle.is_null() {
1384        return NetError::NullPointer.into();
1385    }
1386    if count > 0 && (payloads.is_null() || lens.is_null()) {
1387        return NetError::NullPointer.into();
1388    }
1389    let sh = unsafe { &*handle };
1390    let nh = unsafe { &*node_handle };
1391    // Gate both handles; either being freed concurrently would
1392    // otherwise UAF the inner deref below.
1393    let _sh_op = match sh.guard.try_enter() {
1394        Some(op) => op,
1395        None => return NetError::ShuttingDown.into(),
1396    };
1397    let _nh_op = match nh.guard.try_enter() {
1398        Some(op) => op,
1399        None => return NetError::ShuttingDown.into(),
1400    };
1401    if !handles_match(sh, nh) {
1402        return NetError::MismatchedHandles.into();
1403    }
1404    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1405        Some(v) => v,
1406        None => return NetError::NullPointer.into(),
1407    };
1408    let node = nh.inner.clone();
1409    let stream = sh.stream.clone();
1410    match block_on(async move { node.send_on_stream(&stream, &payloads).await }) {
1411        Ok(()) => 0,
1412        Err(e) => stream_err_to_code(&e),
1413    }
1414}
1415
1416#[unsafe(no_mangle)]
1417pub unsafe extern "C" fn net_mesh_send_with_retry(
1418    handle: *mut MeshStreamHandle,
1419    payloads: *const *const u8,
1420    lens: *const usize,
1421    count: usize,
1422    max_retries: u32,
1423    node_handle: *mut MeshNodeHandle,
1424) -> c_int {
1425    if handle.is_null() || node_handle.is_null() {
1426        return NetError::NullPointer.into();
1427    }
1428    if count > 0 && (payloads.is_null() || lens.is_null()) {
1429        return NetError::NullPointer.into();
1430    }
1431    let sh = unsafe { &*handle };
1432    let nh = unsafe { &*node_handle };
1433    // Gate both handles; either being freed concurrently would
1434    // otherwise UAF the inner deref below.
1435    let _sh_op = match sh.guard.try_enter() {
1436        Some(op) => op,
1437        None => return NetError::ShuttingDown.into(),
1438    };
1439    let _nh_op = match nh.guard.try_enter() {
1440        Some(op) => op,
1441        None => return NetError::ShuttingDown.into(),
1442    };
1443    if !handles_match(sh, nh) {
1444        return NetError::MismatchedHandles.into();
1445    }
1446    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1447        Some(v) => v,
1448        None => return NetError::NullPointer.into(),
1449    };
1450    let node = nh.inner.clone();
1451    let stream = sh.stream.clone();
1452    match block_on(async move {
1453        node.send_with_retry(&stream, &payloads, max_retries as usize)
1454            .await
1455    }) {
1456        Ok(()) => 0,
1457        Err(e) => stream_err_to_code(&e),
1458    }
1459}
1460
1461#[unsafe(no_mangle)]
1462pub unsafe extern "C" fn net_mesh_send_blocking(
1463    handle: *mut MeshStreamHandle,
1464    payloads: *const *const u8,
1465    lens: *const usize,
1466    count: usize,
1467    node_handle: *mut MeshNodeHandle,
1468) -> c_int {
1469    if handle.is_null() || node_handle.is_null() {
1470        return NetError::NullPointer.into();
1471    }
1472    if count > 0 && (payloads.is_null() || lens.is_null()) {
1473        return NetError::NullPointer.into();
1474    }
1475    let sh = unsafe { &*handle };
1476    let nh = unsafe { &*node_handle };
1477    // Gate both handles; either being freed concurrently would
1478    // otherwise UAF the inner deref below.
1479    let _sh_op = match sh.guard.try_enter() {
1480        Some(op) => op,
1481        None => return NetError::ShuttingDown.into(),
1482    };
1483    let _nh_op = match nh.guard.try_enter() {
1484        Some(op) => op,
1485        None => return NetError::ShuttingDown.into(),
1486    };
1487    if !handles_match(sh, nh) {
1488        return NetError::MismatchedHandles.into();
1489    }
1490    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1491        Some(v) => v,
1492        None => return NetError::NullPointer.into(),
1493    };
1494    let node = nh.inner.clone();
1495    let stream = sh.stream.clone();
1496    match block_on(async move { node.send_blocking(&stream, &payloads).await }) {
1497        Ok(()) => 0,
1498        Err(e) => stream_err_to_code(&e),
1499    }
1500}
1501
1502#[derive(Serialize)]
1503struct StreamStatsJson {
1504    tx_seq: u64,
1505    rx_seq: u64,
1506    inbound_pending: u64,
1507    last_activity_ns: u64,
1508    active: bool,
1509    backpressure_events: u64,
1510    tx_credit_remaining: u32,
1511    tx_window: u32,
1512    credit_grants_received: u64,
1513    credit_grants_sent: u64,
1514}
1515
1516#[unsafe(no_mangle)]
1517pub unsafe extern "C" fn net_mesh_stream_stats(
1518    node_handle: *mut MeshNodeHandle,
1519    peer_node_id: u64,
1520    stream_id: u64,
1521    out_json: *mut *mut c_char,
1522    out_len: *mut usize,
1523) -> c_int {
1524    if node_handle.is_null() || out_json.is_null() || out_len.is_null() {
1525        return NetError::NullPointer.into();
1526    }
1527    let h = unsafe { &*node_handle };
1528    let _op = match h.guard.try_enter() {
1529        Some(op) => op,
1530        None => return NetError::ShuttingDown.into(),
1531    };
1532    match h.inner.stream_stats(peer_node_id, stream_id) {
1533        Some(s) => {
1534            let js = StreamStatsJson {
1535                tx_seq: s.tx_seq,
1536                rx_seq: s.rx_seq,
1537                inbound_pending: s.inbound_pending,
1538                last_activity_ns: s.last_activity_ns,
1539                active: s.active,
1540                backpressure_events: s.backpressure_events,
1541                tx_credit_remaining: s.tx_credit_remaining,
1542                tx_window: s.tx_window,
1543                credit_grants_received: s.credit_grants_received,
1544                credit_grants_sent: s.credit_grants_sent,
1545            };
1546            write_json_out(&js, out_json, out_len)
1547        }
1548        None => {
1549            // Encode `null` so Go can distinguish "no such stream"
1550            // from an error.
1551            write_string_out("null".to_string(), out_json, out_len)
1552        }
1553    }
1554}
1555
1556// =========================================================================
1557// Shard receive
1558// =========================================================================
1559
1560#[derive(Serialize)]
1561struct RecvEventJson {
1562    id: String,
1563    /// Base64 payload (binary-safe across the JSON boundary).
1564    payload_b64: String,
1565    insertion_ts: u64,
1566    shard_id: u16,
1567}
1568
1569#[unsafe(no_mangle)]
1570pub unsafe extern "C" fn net_mesh_recv_shard(
1571    handle: *mut MeshNodeHandle,
1572    shard_id: u16,
1573    limit: u32,
1574    out_json: *mut *mut c_char,
1575    out_len: *mut usize,
1576) -> c_int {
1577    if handle.is_null() || out_json.is_null() || out_len.is_null() {
1578        return NetError::NullPointer.into();
1579    }
1580    let h = unsafe { &*handle };
1581    let _op = match h.guard.try_enter() {
1582        Some(op) => op,
1583        None => return NetError::ShuttingDown.into(),
1584    };
1585    let node = h.inner.clone();
1586    let result = block_on(async move { node.poll_shard(shard_id, None, limit as usize).await });
1587    let result = match result {
1588        Ok(r) => r,
1589        Err(e) => return adapter_err_to_code(&e),
1590    };
1591    let events: Vec<RecvEventJson> = result
1592        .events
1593        .into_iter()
1594        .map(|e| RecvEventJson {
1595            id: e.id,
1596            payload_b64: encode_b64(&e.raw),
1597            insertion_ts: e.insertion_ts,
1598            shard_id: e.shard_id,
1599        })
1600        .collect();
1601    write_json_out(&events, out_json, out_len)
1602}
1603
1604fn encode_b64(bytes: &[u8]) -> String {
1605    // Small stdlib-free base64. Net already pulls in `base64` via
1606    // other deps, but a local encoder keeps this module independent.
1607    const ALPH: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1608    let mut s = String::with_capacity(bytes.len().div_ceil(3) * 4);
1609    let mut i = 0;
1610    while i + 3 <= bytes.len() {
1611        let chunk = &bytes[i..i + 3];
1612        s.push(ALPH[(chunk[0] >> 2) as usize] as char);
1613        s.push(ALPH[(((chunk[0] & 0b11) << 4) | (chunk[1] >> 4)) as usize] as char);
1614        s.push(ALPH[(((chunk[1] & 0b1111) << 2) | (chunk[2] >> 6)) as usize] as char);
1615        s.push(ALPH[(chunk[2] & 0b111111) as usize] as char);
1616        i += 3;
1617    }
1618    let rem = bytes.len() - i;
1619    if rem == 1 {
1620        let b = bytes[i];
1621        s.push(ALPH[(b >> 2) as usize] as char);
1622        s.push(ALPH[((b & 0b11) << 4) as usize] as char);
1623        s.push('=');
1624        s.push('=');
1625    } else if rem == 2 {
1626        let b0 = bytes[i];
1627        let b1 = bytes[i + 1];
1628        s.push(ALPH[(b0 >> 2) as usize] as char);
1629        s.push(ALPH[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
1630        s.push(ALPH[((b1 & 0b1111) << 2) as usize] as char);
1631        s.push('=');
1632    }
1633    s
1634}
1635
1636// =========================================================================
1637// Channels (distributed pub/sub)
1638// =========================================================================
1639
1640#[derive(Deserialize)]
1641struct ChannelConfigInput {
1642    name: String,
1643    visibility: Option<String>,
1644    reliable: Option<bool>,
1645    require_token: Option<bool>,
1646    priority: Option<u8>,
1647    max_rate_pps: Option<u32>,
1648    /// Capability filter restricting who may publish on this
1649    /// channel. Same POJO shape as `CapabilityFilter` (see
1650    /// `net_mesh_find_nodes`).
1651    publish_caps: Option<CapabilityFilterJson>,
1652    /// Capability filter restricting who may subscribe. Subscribers
1653    /// whose announced caps miss this filter are rejected with
1654    /// `NET_ERR_CHANNEL_AUTH`.
1655    subscribe_caps: Option<CapabilityFilterJson>,
1656}
1657
1658fn parse_visibility(s: &str) -> Option<InnerVisibility> {
1659    match s {
1660        "subnet-local" => Some(InnerVisibility::SubnetLocal),
1661        "parent-visible" => Some(InnerVisibility::ParentVisible),
1662        "exported" => Some(InnerVisibility::Exported),
1663        "global" => Some(InnerVisibility::Global),
1664        _ => None,
1665    }
1666}
1667
1668#[unsafe(no_mangle)]
1669pub unsafe extern "C" fn net_mesh_register_channel(
1670    handle: *mut MeshNodeHandle,
1671    config_json: *const c_char,
1672) -> c_int {
1673    if handle.is_null() || config_json.is_null() {
1674        return NetError::NullPointer.into();
1675    }
1676    let h = unsafe { &*handle };
1677    let _op = match h.guard.try_enter() {
1678        Some(op) => op,
1679        None => return NetError::ShuttingDown.into(),
1680    };
1681    let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
1682        return NetError::InvalidUtf8.into();
1683    };
1684    let input: ChannelConfigInput = match serde_json::from_str(&s) {
1685        Ok(v) => v,
1686        Err(_) => return NetError::InvalidJson.into(),
1687    };
1688    let name = match InnerChannelName::new(&input.name) {
1689        Ok(n) => n,
1690        Err(_) => return NET_ERR_CHANNEL,
1691    };
1692    let mut cfg = InnerChannelConfig::new(ChannelId::new(name));
1693    if let Some(v) = input.visibility {
1694        let Some(vis) = parse_visibility(&v) else {
1695            return NET_ERR_CHANNEL;
1696        };
1697        cfg = cfg.with_visibility(vis);
1698    }
1699    if let Some(r) = input.reliable {
1700        cfg = cfg.with_reliable(r);
1701    }
1702    if let Some(t) = input.require_token {
1703        cfg = cfg.with_require_token(t);
1704    }
1705    if let Some(p) = input.priority {
1706        cfg = cfg.with_priority(p);
1707    }
1708    if let Some(pps) = input.max_rate_pps {
1709        cfg = cfg.with_rate_limit(pps);
1710    }
1711    if let Some(filter_json) = input.publish_caps {
1712        cfg = cfg.with_publish_caps(capability_filter_from_json(filter_json));
1713    }
1714    if let Some(filter_json) = input.subscribe_caps {
1715        cfg = cfg.with_subscribe_caps(capability_filter_from_json(filter_json));
1716    }
1717    h.channel_configs.insert(cfg);
1718    0
1719}
1720
1721#[unsafe(no_mangle)]
1722pub unsafe extern "C" fn net_mesh_subscribe_channel(
1723    handle: *mut MeshNodeHandle,
1724    publisher_node_id: u64,
1725    channel: *const c_char,
1726) -> c_int {
1727    subscribe_or_unsubscribe(handle, publisher_node_id, channel, true)
1728}
1729
1730#[unsafe(no_mangle)]
1731pub unsafe extern "C" fn net_mesh_unsubscribe_channel(
1732    handle: *mut MeshNodeHandle,
1733    publisher_node_id: u64,
1734    channel: *const c_char,
1735) -> c_int {
1736    subscribe_or_unsubscribe(handle, publisher_node_id, channel, false)
1737}
1738
1739/// Subscribe with a serialized `PermissionToken` attached. Parses
1740/// the token client-side (rejecting malformed bytes with
1741/// `NET_ERR_TOKEN_INVALID_FORMAT`) before dispatching the request
1742/// to the publisher. Signature verification happens on the
1743/// publisher side; a tampered token will surface as
1744/// `NET_ERR_CHANNEL_AUTH` rather than a token error in this call.
1745#[unsafe(no_mangle)]
1746pub unsafe extern "C" fn net_mesh_subscribe_channel_with_token(
1747    handle: *mut MeshNodeHandle,
1748    publisher_node_id: u64,
1749    channel: *const c_char,
1750    token: *const u8,
1751    token_len: usize,
1752) -> c_int {
1753    if handle.is_null() || channel.is_null() || token.is_null() {
1754        return NetError::NullPointer.into();
1755    }
1756    let h = unsafe { &*handle };
1757    let _op = match h.guard.try_enter() {
1758        Some(op) => op,
1759        None => return NetError::ShuttingDown.into(),
1760    };
1761    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
1762        return NetError::InvalidUtf8.into();
1763    };
1764    let name = match InnerChannelName::new(&s) {
1765        Ok(n) => n,
1766        Err(_) => return NET_ERR_CHANNEL,
1767    };
1768    // `slice::from_raw_parts` requires `len <= isize::MAX`.
1769    if token_len > isize::MAX as usize {
1770        return NetError::InvalidJson.into();
1771    }
1772    let slice = unsafe { std::slice::from_raw_parts(token, token_len) };
1773    let parsed = match PermissionToken::from_bytes(slice) {
1774        Ok(t) => t,
1775        Err(e) => return token_err_to_code(&e),
1776    };
1777    let node = h.inner.clone();
1778    match block_on(async move {
1779        node.subscribe_channel_with_token(publisher_node_id, name, parsed)
1780            .await
1781    }) {
1782        Ok(()) => 0,
1783        Err(e) => adapter_err_to_channel_code(&e),
1784    }
1785}
1786
1787fn subscribe_or_unsubscribe(
1788    handle: *mut MeshNodeHandle,
1789    publisher_node_id: u64,
1790    channel: *const c_char,
1791    subscribe: bool,
1792) -> c_int {
1793    if handle.is_null() || channel.is_null() {
1794        return NetError::NullPointer.into();
1795    }
1796    let h = unsafe { &*handle };
1797    let _op = match h.guard.try_enter() {
1798        Some(op) => op,
1799        None => return NetError::ShuttingDown.into(),
1800    };
1801    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
1802        return NetError::InvalidUtf8.into();
1803    };
1804    let name = match InnerChannelName::new(&s) {
1805        Ok(n) => n,
1806        Err(_) => return NET_ERR_CHANNEL,
1807    };
1808    let node = h.inner.clone();
1809    let outcome = if subscribe {
1810        block_on(async move { node.subscribe_channel(publisher_node_id, name).await })
1811    } else {
1812        block_on(async move { node.unsubscribe_channel(publisher_node_id, name).await })
1813    };
1814    match outcome {
1815        Ok(()) => 0,
1816        Err(e) => adapter_err_to_channel_code(&e),
1817    }
1818}
1819
1820fn adapter_err_to_channel_code(err: &AdapterError) -> c_int {
1821    if let AdapterError::Connection(msg) = err {
1822        let prefix = "membership request rejected: ";
1823        if let Some(tail) = msg.strip_prefix(prefix) {
1824            if tail.trim() == "Some(Unauthorized)" {
1825                return NET_ERR_CHANNEL_AUTH;
1826            }
1827        }
1828    }
1829    NET_ERR_CHANNEL
1830}
1831
1832#[derive(Deserialize, Default)]
1833struct PublishConfigInput {
1834    reliability: Option<String>,
1835    on_failure: Option<String>,
1836    max_inflight: Option<u32>,
1837}
1838
1839#[derive(Serialize)]
1840struct PublishReportJson {
1841    attempted: u32,
1842    delivered: u32,
1843    errors: Vec<PublishFailureJson>,
1844}
1845
1846#[derive(Serialize)]
1847struct PublishFailureJson {
1848    node_id: u64,
1849    message: String,
1850}
1851
1852fn to_publish_report_json(r: InnerPublishReport) -> PublishReportJson {
1853    PublishReportJson {
1854        attempted: r.attempted as u32,
1855        delivered: r.delivered as u32,
1856        errors: r
1857            .errors
1858            .into_iter()
1859            .map(|(id, e)| PublishFailureJson {
1860                node_id: id,
1861                message: format!("{}", e),
1862            })
1863            .collect(),
1864    }
1865}
1866
1867#[unsafe(no_mangle)]
1868pub unsafe extern "C" fn net_mesh_publish(
1869    handle: *mut MeshNodeHandle,
1870    channel: *const c_char,
1871    payload: *const u8,
1872    len: usize,
1873    config_json: *const c_char,
1874    out_json: *mut *mut c_char,
1875    out_len: *mut usize,
1876) -> c_int {
1877    if handle.is_null() || channel.is_null() || out_json.is_null() || out_len.is_null() {
1878        return NetError::NullPointer.into();
1879    }
1880    let h = unsafe { &*handle };
1881    let _op = match h.guard.try_enter() {
1882        Some(op) => op,
1883        None => return NetError::ShuttingDown.into(),
1884    };
1885    let Some(ch) = (unsafe { c_str_to_string(channel) }) else {
1886        return NetError::InvalidUtf8.into();
1887    };
1888    let name = match InnerChannelName::new(&ch) {
1889        Ok(n) => n,
1890        Err(_) => return NET_ERR_CHANNEL,
1891    };
1892    let cfg_in: PublishConfigInput = if config_json.is_null() {
1893        PublishConfigInput::default()
1894    } else {
1895        let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
1896            return NetError::InvalidUtf8.into();
1897        };
1898        match serde_json::from_str(&s) {
1899            Ok(v) => v,
1900            Err(_) => return NetError::InvalidJson.into(),
1901        }
1902    };
1903    let reliability = match cfg_in.reliability.as_deref() {
1904        None | Some("fire_and_forget") => Reliability::FireAndForget,
1905        Some("reliable") => Reliability::Reliable,
1906        Some(_) => return NET_ERR_CHANNEL,
1907    };
1908    let on_failure = match cfg_in.on_failure.as_deref() {
1909        None | Some("best_effort") => InnerOnFailure::BestEffort,
1910        Some("fail_fast") => InnerOnFailure::FailFast,
1911        Some("collect") => InnerOnFailure::Collect,
1912        Some(_) => return NET_ERR_CHANNEL,
1913    };
1914    let max_inflight = cfg_in.max_inflight.unwrap_or(32) as usize;
1915    let publish_cfg = InnerPublishConfig {
1916        reliability,
1917        on_failure,
1918        max_inflight,
1919    };
1920    let publisher = ChannelPublisher::new(name, publish_cfg);
1921
1922    // Payload may be NULL only when len == 0.
1923    let bytes = if len == 0 {
1924        Bytes::new()
1925    } else if payload.is_null() {
1926        return NetError::NullPointer.into();
1927    } else if len > isize::MAX as usize {
1928        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1929        return NetError::InvalidJson.into();
1930    } else {
1931        Bytes::copy_from_slice(unsafe { std::slice::from_raw_parts(payload, len) })
1932    };
1933
1934    let node = h.inner.clone();
1935    match block_on(async move { node.publish(&publisher, bytes).await }) {
1936        Ok(report) => {
1937            let js = to_publish_report_json(report);
1938            write_json_out(&js, out_json, out_len)
1939        }
1940        Err(e) => adapter_err_to_channel_code(&e),
1941    }
1942}
1943
1944// =========================================================================
1945// Identity + permission tokens
1946// =========================================================================
1947
1948/// Opaque handle holding an ed25519 keypair plus a local
1949/// `TokenCache`. Matches the PyO3 / NAPI `Identity` pyclass layout —
1950/// cheap to clone (both fields are `Arc`s inside the core), and the
1951/// cache is owned by the handle rather than shared across peers.
1952///
1953/// Same `HandleGuard` recipe as the cortex handles (see
1954/// `super::handle_guard` for soundness). Box stays leaked across
1955/// `_free`; inner Arcs live in `ManuallyDrop` so the free can
1956/// take and drop them after quiescing in-flight ops.
1957pub struct IdentityHandle {
1958    keypair: ManuallyDrop<Arc<EntityKeypair>>,
1959    cache: ManuallyDrop<Arc<TokenCache>>,
1960    guard: HandleGuard,
1961}
1962
1963/// Allocate and copy `src` into a freshly allocated buffer owned by
1964/// `std::alloc::alloc` with a layout of `Layout::array::<u8>(len)`.
1965/// The matching `net_free_bytes` must deallocate with the same layout
1966/// — both sides pin the capacity to `len`, so there is no reliance on
1967/// `Vec::shrink_to_fit` producing `capacity == len` (which is not
1968/// guaranteed by the allocator API).
1969///
1970/// Returns `NetError::NullPointer` (the FFI-safe sentinel) if either
1971/// out-pointer is null. Every current call site filters nulls at the
1972/// public `extern "C"` entry before reaching here, so this check is
1973/// defence-in-depth — its purpose is to make `alloc_bytes` safe to
1974/// reuse from future call sites without retracing the null-handling
1975/// contract.
1976fn alloc_bytes(src: &[u8], out_ptr: *mut *mut u8, out_len: *mut usize) -> c_int {
1977    if out_ptr.is_null() || out_len.is_null() {
1978        return NetError::NullPointer.into();
1979    }
1980    let len = src.len();
1981    if len == 0 {
1982        unsafe {
1983            *out_ptr = std::ptr::null_mut();
1984            *out_len = 0;
1985        }
1986        return 0;
1987    }
1988    // `Layout::array::<u8>(len)` rejects `len > isize::MAX` (the
1989    // documented bound — NOT `usize::MAX`). The current call
1990    // sites stay well under that limit because `to_bytes()`
1991    // produces token-sized payloads, so the failure mode is
1992    // unreachable today; defending against it here also keeps the
1993    // helper safe to reuse from non-token code paths in the
1994    // future. A panic here would unwind across the surrounding
1995    // `extern "C"` boundary.
1996    let layout = match std::alloc::Layout::array::<u8>(len) {
1997        Ok(l) => l,
1998        // Reuse the closest sentinel we have — `NET_ERR_IDENTITY`
1999        // covers the only call sites today (token/identity helpers
2000        // that delegate to `alloc_bytes`). The negative integer is
2001        // an FFI-safe error code; the alternative `panic!` would
2002        // unwind across `extern "C"`.
2003        Err(_) => return NET_ERR_IDENTITY,
2004    };
2005    let ptr = unsafe { std::alloc::alloc(layout) };
2006    if ptr.is_null() {
2007        std::alloc::handle_alloc_error(layout);
2008    }
2009    unsafe {
2010        std::ptr::copy_nonoverlapping(src.as_ptr(), ptr, len);
2011        *out_ptr = ptr;
2012        *out_len = len;
2013    }
2014    0
2015}
2016
2017/// Free a byte buffer allocated by the Rust side (tokens, entity ids
2018/// returned by reference, etc.). The `len` argument MUST match the
2019/// length returned by the allocating call — the buffer was allocated
2020/// with `Layout::array::<u8>(len)` and is freed with the same layout.
2021///
2022/// We silently no-op on `len > isize::MAX`: the allocation that
2023/// produced `ptr` could not have come from this process under that
2024/// layout (the allocator would have rejected the matching
2025/// `alloc`), so any such call is already memory-corruption
2026/// territory and the safest response is to abandon the free rather
2027/// than unwind. `net_free_bytes` is `extern "C"` with no
2028/// `catch_unwind` shim, so a panic would unwind across the FFI
2029/// boundary into a C / Go-cgo / NAPI / PyO3 caller — undefined
2030/// behaviour.
2031#[unsafe(no_mangle)]
2032pub unsafe extern "C" fn net_free_bytes(ptr: *mut u8, len: usize) {
2033    if ptr.is_null() || len == 0 {
2034        return;
2035    }
2036    // Reject `len > isize::MAX` before calling `Layout::array`. The
2037    // allocating call paired with this free uses the same layout and
2038    // would itself have failed for any such `len`, so a buffer
2039    // matching this `len` cannot have come from us; treat as a no-op
2040    // rather than panic across the FFI boundary.
2041    let layout = match std::alloc::Layout::array::<u8>(len) {
2042        Ok(l) => l,
2043        Err(_) => return,
2044    };
2045    unsafe {
2046        std::alloc::dealloc(ptr, layout);
2047    }
2048}
2049
2050fn entity_id_from_bytes(bytes: *const u8, len: usize) -> Option<EntityId> {
2051    if bytes.is_null() || len != 32 {
2052        return None;
2053    }
2054    let slice = unsafe { std::slice::from_raw_parts(bytes, 32) };
2055    let mut arr = [0u8; 32];
2056    arr.copy_from_slice(slice);
2057    Some(EntityId::from_bytes(arr))
2058}
2059
2060fn parse_scope_list(raw: &str) -> Option<TokenScope> {
2061    // JSON array of string scope names — same shape as PyO3's
2062    // `Vec<String>` parsing. Keeps the ABI aligned to the Python /
2063    // NAPI surfaces for round-trip fixtures.
2064    let values: Vec<String> = serde_json::from_str(raw).ok()?;
2065    let mut acc = TokenScope::NONE;
2066    for s in &values {
2067        acc = acc.union(match s.as_str() {
2068            "publish" => TokenScope::PUBLISH,
2069            "subscribe" => TokenScope::SUBSCRIBE,
2070            "admin" => TokenScope::ADMIN,
2071            "delegate" => TokenScope::DELEGATE,
2072            _ => return None,
2073        });
2074    }
2075    Some(acc)
2076}
2077
2078fn scope_to_strings(scope: TokenScope) -> Vec<&'static str> {
2079    let mut out = Vec::new();
2080    if scope.contains(TokenScope::PUBLISH) {
2081        out.push("publish");
2082    }
2083    if scope.contains(TokenScope::SUBSCRIBE) {
2084        out.push("subscribe");
2085    }
2086    if scope.contains(TokenScope::ADMIN) {
2087        out.push("admin");
2088    }
2089    if scope.contains(TokenScope::DELEGATE) {
2090        out.push("delegate");
2091    }
2092    out
2093}
2094
2095fn channel_name_to_hash(channel: &str) -> Option<ChannelHash> {
2096    InnerChannelName::new(channel).ok().map(|n| n.hash())
2097}
2098
2099/// Generate a fresh ed25519 identity. Writes an owned handle to
2100/// `*out_handle`. Free via `net_identity_free`.
2101#[unsafe(no_mangle)]
2102pub unsafe extern "C" fn net_identity_generate(out_handle: *mut *mut IdentityHandle) -> c_int {
2103    if out_handle.is_null() {
2104        return NetError::NullPointer.into();
2105    }
2106    let handle = Box::new(IdentityHandle {
2107        keypair: ManuallyDrop::new(Arc::new(EntityKeypair::generate())),
2108        cache: ManuallyDrop::new(Arc::new(TokenCache::new())),
2109        guard: HandleGuard::new(),
2110    });
2111    unsafe {
2112        *out_handle = Box::into_raw(handle);
2113    }
2114    0
2115}
2116
2117/// Construct an identity from a caller-owned 32-byte ed25519 seed.
2118/// Installs a fresh, empty `TokenCache` — reinstall tokens via
2119/// `net_identity_install_token` after rehydrating from disk.
2120#[unsafe(no_mangle)]
2121pub unsafe extern "C" fn net_identity_from_seed(
2122    seed: *const u8,
2123    seed_len: usize,
2124    out_handle: *mut *mut IdentityHandle,
2125) -> c_int {
2126    if seed.is_null() || out_handle.is_null() {
2127        return NetError::NullPointer.into();
2128    }
2129    if seed_len != 32 {
2130        return NET_ERR_IDENTITY;
2131    }
2132    let mut arr = [0u8; 32];
2133    arr.copy_from_slice(unsafe { std::slice::from_raw_parts(seed, 32) });
2134    let handle = Box::new(IdentityHandle {
2135        keypair: ManuallyDrop::new(Arc::new(EntityKeypair::from_bytes(arr))),
2136        cache: ManuallyDrop::new(Arc::new(TokenCache::new())),
2137        guard: HandleGuard::new(),
2138    });
2139    unsafe {
2140        *out_handle = Box::into_raw(handle);
2141    }
2142    0
2143}
2144
2145#[unsafe(no_mangle)]
2146pub unsafe extern "C" fn net_identity_free(handle: *mut IdentityHandle) {
2147    if handle.is_null() {
2148        return;
2149    }
2150    // Quiesce in-flight ops before dropping inner; box leaked.
2151    let h: &IdentityHandle = unsafe { &*handle };
2152    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
2153        // SAFETY: drained; sole writable reference.
2154        unsafe {
2155            let mh = &mut *handle;
2156            let kp = ManuallyDrop::take(&mut mh.keypair);
2157            let cache = ManuallyDrop::take(&mut mh.cache);
2158            drop(kp);
2159            drop(cache);
2160        }
2161    } else {
2162        tracing::warn!(
2163            "net_identity_free: in-flight ops did not drain within deadline; \
2164             leaking inner to avoid use-after-free"
2165        );
2166    }
2167}
2168
2169/// Write the 32-byte ed25519 seed into `out[32]`. Caller must pass
2170/// a buffer of at least 32 bytes.
2171#[unsafe(no_mangle)]
2172pub unsafe extern "C" fn net_identity_to_seed(handle: *mut IdentityHandle, out: *mut u8) -> c_int {
2173    if handle.is_null() || out.is_null() {
2174        return NetError::NullPointer.into();
2175    }
2176    let h = unsafe { &*handle };
2177    let _op = match h.guard.try_enter() {
2178        Some(op) => op,
2179        None => return NetError::ShuttingDown.into(),
2180    };
2181    let seed = h.keypair.secret_bytes();
2182    unsafe {
2183        std::ptr::copy_nonoverlapping(seed.as_ptr(), out, 32);
2184    }
2185    0
2186}
2187
2188/// Write the 32-byte entity id into `out[32]`.
2189#[unsafe(no_mangle)]
2190pub unsafe extern "C" fn net_identity_entity_id(
2191    handle: *mut IdentityHandle,
2192    out: *mut u8,
2193) -> c_int {
2194    if handle.is_null() || out.is_null() {
2195        return NetError::NullPointer.into();
2196    }
2197    let h = unsafe { &*handle };
2198    let _op = match h.guard.try_enter() {
2199        Some(op) => op,
2200        None => return NetError::ShuttingDown.into(),
2201    };
2202    let id = h.keypair.entity_id().as_bytes();
2203    unsafe {
2204        std::ptr::copy_nonoverlapping(id.as_ptr(), out, 32);
2205    }
2206    0
2207}
2208
2209#[unsafe(no_mangle)]
2210pub unsafe extern "C" fn net_identity_node_id(handle: *mut IdentityHandle) -> u64 {
2211    if handle.is_null() {
2212        return 0;
2213    }
2214    let h = unsafe { &*handle };
2215    // Returns 0 on shutting-down — same shape as absent-handle.
2216    let _op = match h.guard.try_enter() {
2217        Some(op) => op,
2218        None => return 0,
2219    };
2220    h.keypair.node_id()
2221}
2222
2223#[unsafe(no_mangle)]
2224pub unsafe extern "C" fn net_identity_origin_hash(handle: *mut IdentityHandle) -> u64 {
2225    if handle.is_null() {
2226        return 0;
2227    }
2228    let h = unsafe { &*handle };
2229    // Returns 0 on shutting-down — same shape as absent-handle.
2230    let _op = match h.guard.try_enter() {
2231        Some(op) => op,
2232        None => return 0,
2233    };
2234    h.keypair.origin_hash()
2235}
2236
2237/// Sign `msg[len]` with the identity's ed25519 secret key. Writes a
2238/// 64-byte signature into `out_sig[64]`.
2239#[unsafe(no_mangle)]
2240pub unsafe extern "C" fn net_identity_sign(
2241    handle: *mut IdentityHandle,
2242    msg: *const u8,
2243    len: usize,
2244    out_sig: *mut u8,
2245) -> c_int {
2246    if handle.is_null() || out_sig.is_null() {
2247        return NetError::NullPointer.into();
2248    }
2249    if len > 0 && msg.is_null() {
2250        return NetError::NullPointer.into();
2251    }
2252    let h = unsafe { &*handle };
2253    let _op = match h.guard.try_enter() {
2254        Some(op) => op,
2255        None => return NetError::ShuttingDown.into(),
2256    };
2257    let slice = if len == 0 {
2258        &[][..]
2259    } else if len > isize::MAX as usize {
2260        // `slice::from_raw_parts` requires `len <= isize::MAX`.
2261        return NetError::InvalidJson.into();
2262    } else {
2263        unsafe { std::slice::from_raw_parts(msg, len) }
2264    };
2265    let sig = h.keypair.sign(slice).to_bytes();
2266    unsafe {
2267        std::ptr::copy_nonoverlapping(sig.as_ptr(), out_sig, 64);
2268    }
2269    0
2270}
2271
2272/// Issue a token to `subject`. Writes a newly-allocated blob to
2273/// `*out_token`; caller frees via `net_free_bytes(ptr, *out_len)`.
2274#[unsafe(no_mangle)]
2275pub unsafe extern "C" fn net_identity_issue_token(
2276    signer: *mut IdentityHandle,
2277    subject: *const u8,
2278    subject_len: usize,
2279    scope_json: *const c_char,
2280    channel: *const c_char,
2281    ttl_seconds: u32,
2282    delegation_depth: u8,
2283    out_token: *mut *mut u8,
2284    out_token_len: *mut usize,
2285) -> c_int {
2286    if signer.is_null() || out_token.is_null() || out_token_len.is_null() {
2287        return NetError::NullPointer.into();
2288    }
2289    let Some(subject_id) = entity_id_from_bytes(subject, subject_len) else {
2290        return NET_ERR_IDENTITY;
2291    };
2292    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
2293        return NetError::InvalidUtf8.into();
2294    };
2295    let Some(scope) = parse_scope_list(&scope_s) else {
2296        return NET_ERR_IDENTITY;
2297    };
2298    let Some(channel_s) = (unsafe { c_str_to_string(channel) }) else {
2299        return NetError::InvalidUtf8.into();
2300    };
2301    let Some(channel_hash) = channel_name_to_hash(&channel_s) else {
2302        return NET_ERR_IDENTITY;
2303    };
2304    let h = unsafe { &*signer };
2305    // Gate before touching `h.keypair` (which lives in
2306    // `ManuallyDrop`). A concurrent `net_identity_free` would
2307    // otherwise drop the keypair while `try_issue` borrows it.
2308    let _op = match h.guard.try_enter() {
2309        Some(op) => op,
2310        None => return NetError::ShuttingDown.into(),
2311    };
2312    // Route through `try_issue` so a public-only signer keypair
2313    // (post-migration zeroize, etc.) surfaces as
2314    // `TokenError::ReadOnly` → `NET_ERR_IDENTITY` instead of
2315    // panic-unwinding across this `extern "C"` frame into the
2316    // caller's binding.
2317    let token = match PermissionToken::try_issue(
2318        &h.keypair,
2319        subject_id,
2320        scope,
2321        channel_hash,
2322        u64::from(ttl_seconds),
2323        delegation_depth,
2324    ) {
2325        Ok(t) => t,
2326        Err(e) => return token_err_to_code(&e),
2327    };
2328    alloc_bytes(&token.to_bytes(), out_token, out_token_len)
2329}
2330
2331/// Install a token received from another issuer. Signature +
2332/// structural checks run on insert; malformed or tampered tokens
2333/// return the relevant `NET_ERR_TOKEN_*` code.
2334#[unsafe(no_mangle)]
2335pub unsafe extern "C" fn net_identity_install_token(
2336    handle: *mut IdentityHandle,
2337    token: *const u8,
2338    len: usize,
2339) -> c_int {
2340    if handle.is_null() || token.is_null() {
2341        return NetError::NullPointer.into();
2342    }
2343    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2344    if len > isize::MAX as usize {
2345        return NetError::InvalidJson.into();
2346    }
2347    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2348    let parsed = match PermissionToken::from_bytes(slice) {
2349        Ok(t) => t,
2350        Err(e) => return token_err_to_code(&e),
2351    };
2352    let h = unsafe { &*handle };
2353    let _op = match h.guard.try_enter() {
2354        Some(op) => op,
2355        None => return NetError::ShuttingDown.into(),
2356    };
2357    match h.cache.insert(parsed) {
2358        Ok(()) => 0,
2359        Err(e) => token_err_to_code(&e),
2360    }
2361}
2362
2363/// Look up a cached token by `(subject, channel)`. Writes a newly-
2364/// allocated blob to `*out_token` on hit; writes `NULL` / `0` on
2365/// miss. Caller must always free on hit via `net_free_bytes`.
2366#[unsafe(no_mangle)]
2367pub unsafe extern "C" fn net_identity_lookup_token(
2368    handle: *mut IdentityHandle,
2369    subject: *const u8,
2370    subject_len: usize,
2371    channel: *const c_char,
2372    out_token: *mut *mut u8,
2373    out_token_len: *mut usize,
2374) -> c_int {
2375    if handle.is_null() || out_token.is_null() || out_token_len.is_null() {
2376        return NetError::NullPointer.into();
2377    }
2378    let Some(subject_id) = entity_id_from_bytes(subject, subject_len) else {
2379        return NET_ERR_IDENTITY;
2380    };
2381    let Some(channel_s) = (unsafe { c_str_to_string(channel) }) else {
2382        return NetError::InvalidUtf8.into();
2383    };
2384    let Some(channel_hash) = channel_name_to_hash(&channel_s) else {
2385        return NET_ERR_IDENTITY;
2386    };
2387    let h = unsafe { &*handle };
2388    let _op = match h.guard.try_enter() {
2389        Some(op) => op,
2390        None => return NetError::ShuttingDown.into(),
2391    };
2392    match h.cache.get(&subject_id, channel_hash) {
2393        Some(token) => alloc_bytes(&token.to_bytes(), out_token, out_token_len),
2394        None => {
2395            unsafe {
2396                *out_token = std::ptr::null_mut();
2397                *out_token_len = 0;
2398            }
2399            0
2400        }
2401    }
2402}
2403
2404#[unsafe(no_mangle)]
2405pub unsafe extern "C" fn net_identity_token_cache_len(handle: *mut IdentityHandle) -> u32 {
2406    if handle.is_null() {
2407        return 0;
2408    }
2409    let h = unsafe { &*handle };
2410    // Returns 0 on shutting-down — same shape as absent-handle.
2411    let _op = match h.guard.try_enter() {
2412        Some(op) => op,
2413        None => return 0,
2414    };
2415    h.cache.len() as u32
2416}
2417
2418// -------------------------------------------------------------------------
2419// Module-level token helpers
2420// -------------------------------------------------------------------------
2421
2422#[derive(Serialize)]
2423struct ParsedTokenJson {
2424    issuer_hex: String,
2425    subject_hex: String,
2426    scope: Vec<&'static str>,
2427    channel_hash: ChannelHash,
2428    not_before: u64,
2429    not_after: u64,
2430    delegation_depth: u8,
2431    nonce: u64,
2432    signature_hex: String,
2433}
2434
2435/// Parse a serialized `PermissionToken` into a JSON dict. Fields are
2436/// hex-encoded on the wire (`issuer_hex`, `subject_hex`,
2437/// `signature_hex`) so the JSON round-trips cleanly. Binary variants
2438/// live on the `Identity` handle.
2439#[unsafe(no_mangle)]
2440pub unsafe extern "C" fn net_parse_token(
2441    token: *const u8,
2442    len: usize,
2443    out_json: *mut *mut c_char,
2444    out_len: *mut usize,
2445) -> c_int {
2446    if token.is_null() || out_json.is_null() || out_len.is_null() {
2447        return NetError::NullPointer.into();
2448    }
2449    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2450    if len > isize::MAX as usize {
2451        return NetError::InvalidJson.into();
2452    }
2453    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2454    let parsed = match PermissionToken::from_bytes(slice) {
2455        Ok(t) => t,
2456        Err(e) => return token_err_to_code(&e),
2457    };
2458    let out = ParsedTokenJson {
2459        issuer_hex: hex::encode(parsed.issuer.as_bytes()),
2460        subject_hex: hex::encode(parsed.subject.as_bytes()),
2461        scope: scope_to_strings(parsed.scope),
2462        channel_hash: parsed.channel_hash,
2463        not_before: parsed.not_before,
2464        not_after: parsed.not_after,
2465        delegation_depth: parsed.delegation_depth,
2466        nonce: parsed.nonce,
2467        signature_hex: hex::encode(parsed.signature),
2468    };
2469    write_json_out(&out, out_json, out_len)
2470}
2471
2472/// Verify a serialized token's ed25519 signature. Writes `1` for
2473/// valid / `0` for tampered-or-wrong-subject. Time-bound validity is
2474/// a separate check — see `net_token_is_expired`.
2475#[unsafe(no_mangle)]
2476pub unsafe extern "C" fn net_verify_token(
2477    token: *const u8,
2478    len: usize,
2479    out_ok: *mut c_int,
2480) -> c_int {
2481    if token.is_null() || out_ok.is_null() {
2482        return NetError::NullPointer.into();
2483    }
2484    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2485    if len > isize::MAX as usize {
2486        return NetError::InvalidJson.into();
2487    }
2488    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2489    let parsed = match PermissionToken::from_bytes(slice) {
2490        Ok(t) => t,
2491        Err(e) => return token_err_to_code(&e),
2492    };
2493    unsafe {
2494        *out_ok = if parsed.verify().is_ok() { 1 } else { 0 };
2495    }
2496    0
2497}
2498
2499/// Writes `1` to `*out_expired` if the token's `not_after` has
2500/// passed; `0` otherwise. Pure time check — a tampered-but-expired
2501/// token still reports `1`. Use `net_verify_token` for signature
2502/// integrity.
2503#[unsafe(no_mangle)]
2504pub unsafe extern "C" fn net_token_is_expired(
2505    token: *const u8,
2506    len: usize,
2507    out_expired: *mut c_int,
2508) -> c_int {
2509    if token.is_null() || out_expired.is_null() {
2510        return NetError::NullPointer.into();
2511    }
2512    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2513    if len > isize::MAX as usize {
2514        return NetError::InvalidJson.into();
2515    }
2516    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2517    let parsed = match PermissionToken::from_bytes(slice) {
2518        Ok(t) => t,
2519        Err(e) => return token_err_to_code(&e),
2520    };
2521    unsafe {
2522        *out_expired = if parsed.is_expired() { 1 } else { 0 };
2523    }
2524    0
2525}
2526
2527/// Delegate a token to a new subject. Returns the child token blob;
2528/// caller frees via `net_free_bytes`.
2529#[unsafe(no_mangle)]
2530pub unsafe extern "C" fn net_delegate_token(
2531    signer: *mut IdentityHandle,
2532    parent: *const u8,
2533    parent_len: usize,
2534    new_subject: *const u8,
2535    new_subject_len: usize,
2536    restricted_scope_json: *const c_char,
2537    out_token: *mut *mut u8,
2538    out_token_len: *mut usize,
2539) -> c_int {
2540    if signer.is_null()
2541        || parent.is_null()
2542        || new_subject.is_null()
2543        || restricted_scope_json.is_null()
2544        || out_token.is_null()
2545        || out_token_len.is_null()
2546    {
2547        return NetError::NullPointer.into();
2548    }
2549    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2550    if parent_len > isize::MAX as usize {
2551        return NetError::InvalidJson.into();
2552    }
2553    let parent_slice = unsafe { std::slice::from_raw_parts(parent, parent_len) };
2554    let parent_tok = match PermissionToken::from_bytes(parent_slice) {
2555        Ok(t) => t,
2556        Err(e) => return token_err_to_code(&e),
2557    };
2558    let Some(subject_id) = entity_id_from_bytes(new_subject, new_subject_len) else {
2559        return NET_ERR_IDENTITY;
2560    };
2561    let Some(scope_s) = (unsafe { c_str_to_string(restricted_scope_json) }) else {
2562        return NetError::InvalidUtf8.into();
2563    };
2564    let Some(scope) = parse_scope_list(&scope_s) else {
2565        return NET_ERR_IDENTITY;
2566    };
2567    let h = unsafe { &*signer };
2568    // Gate before touching `h.keypair` (in `ManuallyDrop`).
2569    // A concurrent `net_identity_free` would otherwise drop the
2570    // keypair while `parent_tok.delegate` borrows it.
2571    let _op = match h.guard.try_enter() {
2572        Some(op) => op,
2573        None => return NetError::ShuttingDown.into(),
2574    };
2575    match parent_tok.delegate(&h.keypair, subject_id, scope) {
2576        Ok(child) => alloc_bytes(&child.to_bytes(), out_token, out_token_len),
2577        Err(e) => token_err_to_code(&e),
2578    }
2579}
2580
2581/// Hash a channel name to its canonical 64-bit [`ChannelHash`]
2582/// (substrate-wide ACL / config / storage key). The 16-bit wire
2583/// hash used by `NetHeader::channel_hash` is the low 16 bits of
2584/// the returned value. Returns `NET_ERR_IDENTITY` for invalid names.
2585#[unsafe(no_mangle)]
2586pub unsafe extern "C" fn net_channel_hash(channel: *const c_char, out_hash: *mut u64) -> c_int {
2587    if channel.is_null() || out_hash.is_null() {
2588        return NetError::NullPointer.into();
2589    }
2590    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
2591        return NetError::InvalidUtf8.into();
2592    };
2593    let Some(hash) = channel_name_to_hash(&s) else {
2594        return NET_ERR_IDENTITY;
2595    };
2596    unsafe {
2597        *out_hash = hash;
2598    }
2599    0
2600}
2601
2602// =========================================================================
2603// Capabilities (announce / find_nodes)
2604// =========================================================================
2605
2606// Local alias to keep the capability helpers out of the mesh module's
2607// import list when the Go surface doesn't need them.
2608use crate::adapter::net::behavior::capability::{
2609    AcceleratorInfo, AcceleratorType, CapabilityFilter, CapabilitySet, GpuInfo, GpuVendor,
2610    HardwareCapabilities, Modality, ModelCapability, ResourceLimits, SoftwareCapabilities,
2611    ToolCapability, TAG_SCOPE_REGION_PREFIX, TAG_SCOPE_SUBNET_LOCAL, TAG_SCOPE_TENANT_PREFIX,
2612};
2613
2614// ----- enum helpers (byte-for-byte mirrors of PyO3/NAPI) ---------------------
2615
2616fn parse_gpu_vendor_cap(s: &str) -> GpuVendor {
2617    match s.to_ascii_lowercase().as_str() {
2618        "nvidia" => GpuVendor::Nvidia,
2619        "amd" => GpuVendor::Amd,
2620        "intel" => GpuVendor::Intel,
2621        "apple" => GpuVendor::Apple,
2622        "qualcomm" => GpuVendor::Qualcomm,
2623        _ => GpuVendor::Unknown,
2624    }
2625}
2626
2627fn gpu_vendor_to_string_cap(v: GpuVendor) -> &'static str {
2628    match v {
2629        GpuVendor::Nvidia => "nvidia",
2630        GpuVendor::Amd => "amd",
2631        GpuVendor::Intel => "intel",
2632        GpuVendor::Apple => "apple",
2633        GpuVendor::Qualcomm => "qualcomm",
2634        GpuVendor::Unknown => "unknown",
2635    }
2636}
2637
2638fn parse_modality_cap(s: &str) -> Option<Modality> {
2639    match s.to_ascii_lowercase().as_str() {
2640        "text" => Some(Modality::Text),
2641        "image" => Some(Modality::Image),
2642        "audio" => Some(Modality::Audio),
2643        "video" => Some(Modality::Video),
2644        "code" => Some(Modality::Code),
2645        "embedding" => Some(Modality::Embedding),
2646        "tool-use" | "tool_use" | "tooluse" => Some(Modality::ToolUse),
2647        // Pre-fix unknown strings (typos) silently fell back to
2648        // `Modality::Text`. For announce-capabilities that meant
2649        // a node advertised "Text" support it didn't actually
2650        // have; for find-nodes filters that meant a typo'd
2651        // constraint (`require_modalities: ["audoi"]`) was
2652        // re-interpreted as "require Text" and returned the
2653        // wrong nodes. Now `None`; callers must handle the
2654        // unknown case explicitly.
2655        _ => None,
2656    }
2657}
2658
2659fn parse_accelerator_type_cap(s: &str) -> AcceleratorType {
2660    match s.to_ascii_lowercase().as_str() {
2661        "tpu" => AcceleratorType::Tpu,
2662        "npu" => AcceleratorType::Npu,
2663        "fpga" => AcceleratorType::Fpga,
2664        "asic" => AcceleratorType::Asic,
2665        "dsp" => AcceleratorType::Dsp,
2666        _ => AcceleratorType::Unknown,
2667    }
2668}
2669
2670// ----- JSON shapes -----------------------------------------------------------
2671
2672#[derive(Deserialize, Default)]
2673struct CapabilitySetJson {
2674    #[serde(default)]
2675    hardware: Option<HardwareJson>,
2676    #[serde(default)]
2677    software: Option<SoftwareJson>,
2678    #[serde(default)]
2679    models: Vec<ModelJson>,
2680    #[serde(default)]
2681    tools: Vec<ToolJson>,
2682    #[serde(default)]
2683    tags: Vec<String>,
2684    #[serde(default)]
2685    limits: Option<LimitsJson>,
2686}
2687
2688#[derive(Deserialize, Default)]
2689struct HardwareJson {
2690    cpu_cores: Option<u32>,
2691    cpu_threads: Option<u32>,
2692    memory_gb: Option<u32>,
2693    gpu: Option<GpuJson>,
2694    #[serde(default)]
2695    additional_gpus: Vec<GpuJson>,
2696    storage_gb: Option<u64>,
2697    network_gbps: Option<u32>,
2698    #[serde(default)]
2699    accelerators: Vec<AcceleratorJson>,
2700}
2701
2702#[derive(Deserialize)]
2703struct GpuJson {
2704    vendor: Option<String>,
2705    #[serde(default)]
2706    model: String,
2707    #[serde(default)]
2708    vram_gb: u32,
2709    compute_units: Option<u32>,
2710    tensor_cores: Option<u32>,
2711    fp16_tflops_x10: Option<u32>,
2712}
2713
2714#[derive(Deserialize)]
2715struct AcceleratorJson {
2716    #[serde(default)]
2717    kind: String,
2718    #[serde(default)]
2719    model: String,
2720    memory_gb: Option<u32>,
2721    tops_x10: Option<u32>,
2722}
2723
2724#[derive(Deserialize, Default)]
2725struct SoftwareJson {
2726    os: Option<String>,
2727    os_version: Option<String>,
2728    #[serde(default)]
2729    runtimes: Vec<Vec<String>>,
2730    #[serde(default)]
2731    frameworks: Vec<Vec<String>>,
2732    cuda_version: Option<String>,
2733    #[serde(default)]
2734    drivers: Vec<Vec<String>>,
2735}
2736
2737#[derive(Deserialize)]
2738struct ModelJson {
2739    #[serde(default)]
2740    model_id: String,
2741    #[serde(default)]
2742    family: String,
2743    parameters_b_x10: Option<u32>,
2744    context_length: Option<u32>,
2745    quantization: Option<String>,
2746    #[serde(default)]
2747    modalities: Vec<String>,
2748    tokens_per_sec: Option<u32>,
2749    loaded: Option<bool>,
2750}
2751
2752#[derive(Deserialize)]
2753struct ToolJson {
2754    #[serde(default)]
2755    tool_id: String,
2756    #[serde(default)]
2757    name: String,
2758    version: Option<String>,
2759    input_schema: Option<String>,
2760    output_schema: Option<String>,
2761    #[serde(default)]
2762    requires: Vec<String>,
2763    estimated_time_ms: Option<u32>,
2764    stateless: Option<bool>,
2765}
2766
2767#[derive(Deserialize, Default)]
2768struct LimitsJson {
2769    max_concurrent_requests: Option<u32>,
2770    max_tokens_per_request: Option<u32>,
2771    rate_limit_rpm: Option<u32>,
2772    max_batch_size: Option<u32>,
2773    max_input_bytes: Option<u32>,
2774    max_output_bytes: Option<u32>,
2775}
2776
2777#[derive(Deserialize, Default)]
2778struct CapabilityFilterJson {
2779    #[serde(default)]
2780    require_tags: Vec<String>,
2781    #[serde(default)]
2782    require_models: Vec<String>,
2783    #[serde(default)]
2784    require_tools: Vec<String>,
2785    min_memory_gb: Option<u32>,
2786    require_gpu: Option<bool>,
2787    gpu_vendor: Option<String>,
2788    min_vram_gb: Option<u32>,
2789    min_context_length: Option<u32>,
2790    #[serde(default)]
2791    require_modalities: Vec<String>,
2792}
2793
2794// ----- Conversions -----------------------------------------------------------
2795
2796fn pair_vec(xs: Vec<Vec<String>>) -> Vec<(String, String)> {
2797    xs.into_iter()
2798        .filter_map(|mut p| {
2799            if p.len() >= 2 {
2800                Some((std::mem::take(&mut p[0]), std::mem::take(&mut p[1])))
2801            } else {
2802                None
2803            }
2804        })
2805        .collect()
2806}
2807
2808/// Clamp an untrusted JSON `u32` into a core `u16` field,
2809/// saturating at `u16::MAX`. Bare `as u16` silently wraps on
2810/// overflow — a Go caller reporting 65536 cores could land 0 on
2811/// the wire. Applied uniformly so every capability JSON
2812/// conversion is consistent with the NAPI + PyO3 paths.
2813#[inline]
2814fn saturating_u16_cap(v: u32) -> u16 {
2815    v.min(u16::MAX as u32) as u16
2816}
2817
2818fn gpu_info_from_json(g: GpuJson) -> GpuInfo {
2819    let vendor = g
2820        .vendor
2821        .as_deref()
2822        .map(parse_gpu_vendor_cap)
2823        .unwrap_or(GpuVendor::Unknown);
2824    let mut info = GpuInfo::new(vendor, g.model, g.vram_gb);
2825    if let Some(cu) = g.compute_units {
2826        info = info.with_compute_units(saturating_u16_cap(cu));
2827    }
2828    if let Some(tc) = g.tensor_cores {
2829        info = info.with_tensor_cores(saturating_u16_cap(tc));
2830    }
2831    if let Some(tf) = g.fp16_tflops_x10 {
2832        // Saturate at `u16::MAX` before the f32 conversion. Pre-fix
2833        // `tf as f32` lost precision for u32 values ≥ 2²⁴ (f32 has
2834        // a 24-bit mantissa), so the round-trip
2835        // `u32 → f32/10.0 → with_fp16_tflops → *10.0 as u32`
2836        // could land a different `fp16_tflops_x10` than the
2837        // operator declared. The neighboring `tops_x10` field
2838        // already routes through `saturating_u16_cap` for the same
2839        // reason; the matching cap here keeps the round-trip exact
2840        // (u16::MAX = 65 535 is far below the f32 precision
2841        // boundary of 2²⁴ = 16 777 216) and aligns the two fields'
2842        // surfaces. The dynamic range loss (2³² → 2¹⁶) is
2843        // acceptable: 6 553.5 TFLOPS is far above any current or
2844        // near-future GPU's fp16 throughput.
2845        let tf_capped = saturating_u16_cap(tf);
2846        info = info.with_fp16_tflops(tf_capped as f32 / 10.0);
2847    }
2848    info
2849}
2850
2851fn accelerator_from_json(a: AcceleratorJson) -> AcceleratorInfo {
2852    AcceleratorInfo {
2853        accel_type: parse_accelerator_type_cap(&a.kind),
2854        model: a.model,
2855        memory_gb: a.memory_gb.unwrap_or(0),
2856        tops_x10: a.tops_x10.map(saturating_u16_cap).unwrap_or(0),
2857    }
2858}
2859
2860fn hardware_from_json(h: HardwareJson) -> HardwareCapabilities {
2861    let mut hw = HardwareCapabilities::new();
2862    match (h.cpu_cores, h.cpu_threads) {
2863        (Some(c), Some(t)) => hw = hw.with_cpu(saturating_u16_cap(c), saturating_u16_cap(t)),
2864        (Some(c), None) => {
2865            let c16 = saturating_u16_cap(c);
2866            hw = hw.with_cpu(c16, c16);
2867        }
2868        _ => {}
2869    }
2870    if let Some(mb) = h.memory_gb {
2871        hw = hw.with_memory(mb);
2872    }
2873    if let Some(g) = h.gpu {
2874        hw = hw.with_gpu(gpu_info_from_json(g));
2875    }
2876    for g in h.additional_gpus {
2877        hw = hw.add_gpu(gpu_info_from_json(g));
2878    }
2879    if let Some(mb) = h.storage_gb {
2880        hw = hw.with_storage(mb);
2881    }
2882    if let Some(gbps) = h.network_gbps {
2883        hw = hw.with_network(gbps);
2884    }
2885    for a in h.accelerators {
2886        hw = hw.add_accelerator(accelerator_from_json(a));
2887    }
2888    hw
2889}
2890
2891fn software_from_json(s: SoftwareJson) -> SoftwareCapabilities {
2892    let mut sw = SoftwareCapabilities::new()
2893        .with_os(s.os.unwrap_or_default(), s.os_version.unwrap_or_default());
2894    for (k, v) in pair_vec(s.runtimes) {
2895        sw = sw.add_runtime(k, v);
2896    }
2897    for (k, v) in pair_vec(s.frameworks) {
2898        sw = sw.add_framework(k, v);
2899    }
2900    if let Some(c) = s.cuda_version {
2901        sw = sw.with_cuda(c);
2902    }
2903    sw.drivers = pair_vec(s.drivers);
2904    sw
2905}
2906
2907fn model_from_json(m: ModelJson) -> ModelCapability {
2908    let mut mc = ModelCapability::new(m.model_id, m.family);
2909    if let Some(p) = m.parameters_b_x10 {
2910        mc.parameters_b_x10 = p;
2911    }
2912    if let Some(c) = m.context_length {
2913        mc = mc.with_context_length(c);
2914    }
2915    if let Some(q) = m.quantization {
2916        mc = mc.with_quantization(q);
2917    }
2918    for modality in m.modalities {
2919        match parse_modality_cap(&modality) {
2920            Some(parsed) => mc = mc.add_modality(parsed),
2921            None => {
2922                tracing::warn!(
2923                    modality = %modality,
2924                    "announce_capabilities: unknown modality string (typo?), \
2925                     skipping rather than the pre-fix silent fallback to Text — \
2926                     advertising a Text capability the node doesn't actually \
2927                     have produced wrong scheduling decisions on the receiver",
2928                );
2929            }
2930        }
2931    }
2932    if let Some(t) = m.tokens_per_sec {
2933        mc = mc.with_tokens_per_sec(t);
2934    }
2935    if let Some(l) = m.loaded {
2936        mc = mc.with_loaded(l);
2937    }
2938    mc
2939}
2940
2941fn tool_from_json(t: ToolJson) -> ToolCapability {
2942    let mut tc = ToolCapability::new(t.tool_id, t.name);
2943    if let Some(v) = t.version {
2944        tc = tc.with_version(v);
2945    }
2946    if let Some(s) = t.input_schema {
2947        tc = tc.with_input_schema(s);
2948    }
2949    if let Some(s) = t.output_schema {
2950        tc = tc.with_output_schema(s);
2951    }
2952    for r in t.requires {
2953        tc = tc.requires(r);
2954    }
2955    if let Some(ms) = t.estimated_time_ms {
2956        tc = tc.with_estimated_time(ms);
2957    }
2958    if let Some(st) = t.stateless {
2959        tc = tc.with_stateless(st);
2960    }
2961    tc
2962}
2963
2964fn limits_from_json(l: LimitsJson) -> ResourceLimits {
2965    let mut rl = ResourceLimits::new();
2966    if let Some(n) = l.max_concurrent_requests {
2967        rl = rl.with_max_concurrent(n);
2968    }
2969    if let Some(n) = l.max_tokens_per_request {
2970        rl = rl.with_max_tokens(n);
2971    }
2972    if let Some(n) = l.rate_limit_rpm {
2973        rl = rl.with_rate_limit(n);
2974    }
2975    if let Some(n) = l.max_batch_size {
2976        rl = rl.with_max_batch(n);
2977    }
2978    if let Some(n) = l.max_input_bytes {
2979        rl.max_input_bytes = n;
2980    }
2981    if let Some(n) = l.max_output_bytes {
2982        rl.max_output_bytes = n;
2983    }
2984    rl
2985}
2986
2987fn capability_set_from_json(caps: CapabilitySetJson) -> CapabilitySet {
2988    let mut cs = CapabilitySet::new();
2989    if let Some(h) = caps.hardware {
2990        cs = cs.with_hardware(hardware_from_json(h));
2991    }
2992    if let Some(s) = caps.software {
2993        cs = cs.with_software(software_from_json(s));
2994    }
2995    for m in caps.models {
2996        cs = cs.add_model(model_from_json(m));
2997    }
2998    for t in caps.tools {
2999        cs = cs.add_tool(tool_from_json(t));
3000    }
3001    // Reserved-prefix scope tags can't go through `add_tag` — it
3002    // uses `Tag::parse_user` which rejects reserved prefixes and
3003    // silently drops them, leaving the announcement with no scope
3004    // and resolving to `CapabilityScope::Global` (visible to every
3005    // tenant / region query). Route the three scope shapes to the
3006    // typed helpers so wire-form `scope:*` strings from bindings
3007    // land as `Tag::Reserved` entries the scope resolver sees.
3008    for tag in caps.tags {
3009        if tag == TAG_SCOPE_SUBNET_LOCAL {
3010            cs = cs.with_subnet_local_scope();
3011        } else if let Some(id) = tag.strip_prefix(TAG_SCOPE_TENANT_PREFIX) {
3012            cs = cs.with_tenant_scope(id);
3013        } else if let Some(name) = tag.strip_prefix(TAG_SCOPE_REGION_PREFIX) {
3014            cs = cs.with_region_scope(name);
3015        } else {
3016            cs = cs.add_tag(tag);
3017        }
3018    }
3019    if let Some(l) = caps.limits {
3020        cs = cs.with_limits(limits_from_json(l));
3021    }
3022    cs
3023}
3024
3025fn capability_filter_from_json(f: CapabilityFilterJson) -> CapabilityFilter {
3026    let mut cf = CapabilityFilter::new();
3027    for t in f.require_tags {
3028        cf = cf.require_tag(t);
3029    }
3030    for m in f.require_models {
3031        cf = cf.require_model(m);
3032    }
3033    for t in f.require_tools {
3034        cf = cf.require_tool(t);
3035    }
3036    if let Some(mb) = f.min_memory_gb {
3037        cf = cf.with_min_memory(mb);
3038    }
3039    if f.require_gpu.unwrap_or(false) {
3040        cf = cf.require_gpu();
3041    }
3042    if let Some(v) = f.gpu_vendor {
3043        cf = cf.with_gpu_vendor(parse_gpu_vendor_cap(&v));
3044    }
3045    if let Some(mb) = f.min_vram_gb {
3046        cf = cf.with_min_vram(mb);
3047    }
3048    if let Some(n) = f.min_context_length {
3049        cf = cf.with_min_context(n);
3050    }
3051    for m in f.require_modalities {
3052        match parse_modality_cap(&m) {
3053            Some(parsed) => cf = cf.require_modality(parsed),
3054            None => {
3055                // For a filter, the lossy direction matters even
3056                // more than for announce: pre-fix the typo'd
3057                // string was re-interpreted as `require Text`,
3058                // returning Text-capable nodes that did NOT
3059                // satisfy the operator's intended constraint.
3060                // Skipping the unknown is also imperfect (the
3061                // resulting filter is too permissive — it
3062                // returns more nodes than intended), but the
3063                // failure mode is "scheduler matched too
3064                // broadly" rather than "scheduler matched the
3065                // wrong type." The loud warn surfaces the typo
3066                // so operators can fix it.
3067                tracing::warn!(
3068                    modality = %m,
3069                    "find_nodes: unknown modality string in require_modalities \
3070                     filter (typo?), dropping the constraint; the resulting \
3071                     filter is too permissive — pre-fix it was silently \
3072                     re-interpreted as `require Text`, which returned the \
3073                     wrong nodes",
3074                );
3075            }
3076        }
3077    }
3078    cf
3079}
3080
3081// ----- Exports ---------------------------------------------------------------
3082
3083pub(crate) const NET_ERR_CAPABILITY: c_int = -128;
3084
3085/// Announce this node's capabilities to every directly-connected
3086/// peer. Also self-indexes, so `find_nodes` on the same node matches
3087/// on the announcement. Multi-hop propagation is deferred.
3088///
3089/// `caps_json` is the same POJO shape as PyO3 / NAPI:
3090/// `{hardware, software, models, tools, tags, limits}`.
3091#[unsafe(no_mangle)]
3092pub unsafe extern "C" fn net_mesh_announce_capabilities(
3093    handle: *mut MeshNodeHandle,
3094    caps_json: *const c_char,
3095) -> c_int {
3096    if handle.is_null() || caps_json.is_null() {
3097        return NetError::NullPointer.into();
3098    }
3099    let h = unsafe { &*handle };
3100    let _op = match h.guard.try_enter() {
3101        Some(op) => op,
3102        None => return NetError::ShuttingDown.into(),
3103    };
3104    let Some(s) = (unsafe { c_str_to_string(caps_json) }) else {
3105        return NetError::InvalidUtf8.into();
3106    };
3107    let parsed: CapabilitySetJson = match serde_json::from_str(&s) {
3108        Ok(v) => v,
3109        Err(_) => return NetError::InvalidJson.into(),
3110    };
3111    let caps = capability_set_from_json(parsed);
3112    let node = h.inner.clone();
3113    match block_on(async move { node.announce_capabilities(caps).await }) {
3114        Ok(()) => 0,
3115        Err(_) => NET_ERR_CAPABILITY,
3116    }
3117}
3118
3119/// Query the local capability index. Writes a JSON array of node
3120/// ids (u64) to `*out_json`; caller frees via `net_free_string`.
3121#[unsafe(no_mangle)]
3122pub unsafe extern "C" fn net_mesh_find_nodes(
3123    handle: *mut MeshNodeHandle,
3124    filter_json: *const c_char,
3125    out_json: *mut *mut c_char,
3126    out_len: *mut usize,
3127) -> c_int {
3128    if handle.is_null() || filter_json.is_null() || out_json.is_null() || out_len.is_null() {
3129        return NetError::NullPointer.into();
3130    }
3131    let h = unsafe { &*handle };
3132    let _op = match h.guard.try_enter() {
3133        Some(op) => op,
3134        None => return NetError::ShuttingDown.into(),
3135    };
3136    let Some(s) = (unsafe { c_str_to_string(filter_json) }) else {
3137        return NetError::InvalidUtf8.into();
3138    };
3139    let parsed: CapabilityFilterJson = match serde_json::from_str(&s) {
3140        Ok(v) => v,
3141        Err(_) => return NetError::InvalidJson.into(),
3142    };
3143    let filter = capability_filter_from_json(parsed);
3144    let ids = h.inner.find_nodes_by_filter(&filter);
3145    write_json_out(&ids, out_json, out_len)
3146}
3147
3148/// JSON shape of a [`ScopeFilter`] for the C ABI. Mirrors the
3149/// NAPI / PyO3 tagged-union form:
3150///
3151/// ```text
3152/// {"kind": "any"}
3153/// {"kind": "global_only"}
3154/// {"kind": "same_subnet"}
3155/// {"kind": "tenant", "tenant": "<id>"}
3156/// {"kind": "tenants", "tenants": ["<id>", ...]}
3157/// {"kind": "region", "region": "<name>"}
3158/// {"kind": "regions", "regions": ["<name>", ...]}
3159/// ```
3160///
3161/// Unrecognized `kind` values fall through to `Any` defensively;
3162/// empty strings or empty lists also collapse to `Any` (matches
3163/// the PyO3 / NAPI converters).
3164#[derive(serde::Deserialize)]
3165struct ScopeFilterJson {
3166    kind: String,
3167    #[serde(default)]
3168    tenant: Option<String>,
3169    #[serde(default)]
3170    tenants: Option<Vec<String>>,
3171    #[serde(default)]
3172    region: Option<String>,
3173    #[serde(default)]
3174    regions: Option<Vec<String>>,
3175}
3176
3177/// Owned scope filter holding the strings the borrowed
3178/// [`net::adapter::net::behavior::capability::ScopeFilter`] points
3179/// into. Constructed inside [`net_mesh_find_nodes_scoped`] and
3180/// dropped at the end of the call so the borrow stays valid for
3181/// the query.
3182enum ScopeFilterOwned {
3183    Any,
3184    GlobalOnly,
3185    SameSubnet,
3186    Tenant(String),
3187    Tenants(Vec<String>),
3188    Region(String),
3189    Regions(Vec<String>),
3190}
3191
3192fn scope_filter_from_json(f: ScopeFilterJson) -> ScopeFilterOwned {
3193    match f.kind.as_str() {
3194        "any" => ScopeFilterOwned::Any,
3195        "global_only" | "globalOnly" => ScopeFilterOwned::GlobalOnly,
3196        "same_subnet" | "sameSubnet" => ScopeFilterOwned::SameSubnet,
3197        "tenant" => match f.tenant {
3198            Some(t) if !t.is_empty() => ScopeFilterOwned::Tenant(t),
3199            _ => ScopeFilterOwned::Any,
3200        },
3201        "tenants" => match f.tenants {
3202            // Drop empty tenant ids — `scope_from_tags` rejects
3203            // empty announcements, so a query containing `[""]`
3204            // would never match a real tenant and would only pin
3205            // to Global candidates. Fall back to Any when cleaned
3206            // list is empty.
3207            Some(ts) => {
3208                let cleaned: Vec<String> = ts.into_iter().filter(|t| !t.is_empty()).collect();
3209                if cleaned.is_empty() {
3210                    ScopeFilterOwned::Any
3211                } else {
3212                    ScopeFilterOwned::Tenants(cleaned)
3213                }
3214            }
3215            None => ScopeFilterOwned::Any,
3216        },
3217        "region" => match f.region {
3218            Some(r) if !r.is_empty() => ScopeFilterOwned::Region(r),
3219            _ => ScopeFilterOwned::Any,
3220        },
3221        "regions" => match f.regions {
3222            // Same reasoning as `tenants` above.
3223            Some(rs) => {
3224                let cleaned: Vec<String> = rs.into_iter().filter(|r| !r.is_empty()).collect();
3225                if cleaned.is_empty() {
3226                    ScopeFilterOwned::Any
3227                } else {
3228                    ScopeFilterOwned::Regions(cleaned)
3229                }
3230            }
3231            None => ScopeFilterOwned::Any,
3232        },
3233        _ => ScopeFilterOwned::Any,
3234    }
3235}
3236
3237/// Run `f` with a borrowed scope filter projected from `owned`.
3238/// Multi-element variants need an intermediate `Vec<&str>` that
3239/// outlives the borrow — that intermediate lives on this call's
3240/// stack, matching the NAPI / PyO3 helpers.
3241fn with_scope_filter<R>(
3242    owned: &ScopeFilterOwned,
3243    f: impl FnOnce(&crate::adapter::net::behavior::capability::ScopeFilter<'_>) -> R,
3244) -> R {
3245    use crate::adapter::net::behavior::capability::ScopeFilter as F;
3246    match owned {
3247        ScopeFilterOwned::Any => f(&F::Any),
3248        ScopeFilterOwned::GlobalOnly => f(&F::GlobalOnly),
3249        ScopeFilterOwned::SameSubnet => f(&F::SameSubnet),
3250        ScopeFilterOwned::Tenant(t) => f(&F::Tenant(t.as_str())),
3251        ScopeFilterOwned::Tenants(ts) => {
3252            let refs: Vec<&str> = ts.iter().map(|s| s.as_str()).collect();
3253            f(&F::Tenants(refs.as_slice()))
3254        }
3255        ScopeFilterOwned::Region(r) => f(&F::Region(r.as_str())),
3256        ScopeFilterOwned::Regions(rs) => {
3257            let refs: Vec<&str> = rs.iter().map(|s| s.as_str()).collect();
3258            f(&F::Regions(refs.as_slice()))
3259        }
3260    }
3261}
3262
3263/// Scoped variant of [`net_mesh_find_nodes`]. Filters candidates
3264/// through a scope filter derived from each node's `scope:*`
3265/// reserved tags. Untagged nodes resolve to `Global` and stay
3266/// visible under most filters; nodes tagged `scope:subnet-local`
3267/// only show up under `{"kind":"same_subnet"}`.
3268///
3269/// `scope_json` is a tagged-union JSON form (see the private
3270/// `ScopeFilterJson` struct above):
3271///
3272/// ```text
3273/// {"kind": "any"}
3274/// {"kind": "global_only"}
3275/// {"kind": "same_subnet"}
3276/// {"kind": "tenant", "tenant": "<id>"}
3277/// {"kind": "tenants", "tenants": ["<id>", ...]}
3278/// {"kind": "region", "region": "<name>"}
3279/// {"kind": "regions", "regions": ["<name>", ...]}
3280/// ```
3281///
3282/// `filter_json` is the same shape as [`net_mesh_find_nodes`].
3283/// Result: JSON array of u64 node ids written to `*out_json`;
3284/// caller frees via `net_free_string`.
3285#[unsafe(no_mangle)]
3286pub unsafe extern "C" fn net_mesh_find_nodes_scoped(
3287    handle: *mut MeshNodeHandle,
3288    filter_json: *const c_char,
3289    scope_json: *const c_char,
3290    out_json: *mut *mut c_char,
3291    out_len: *mut usize,
3292) -> c_int {
3293    if handle.is_null()
3294        || filter_json.is_null()
3295        || scope_json.is_null()
3296        || out_json.is_null()
3297        || out_len.is_null()
3298    {
3299        return NetError::NullPointer.into();
3300    }
3301    let h = unsafe { &*handle };
3302    let _op = match h.guard.try_enter() {
3303        Some(op) => op,
3304        None => return NetError::ShuttingDown.into(),
3305    };
3306    let Some(filter_s) = (unsafe { c_str_to_string(filter_json) }) else {
3307        return NetError::InvalidUtf8.into();
3308    };
3309    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
3310        return NetError::InvalidUtf8.into();
3311    };
3312    let parsed_filter: CapabilityFilterJson = match serde_json::from_str(&filter_s) {
3313        Ok(v) => v,
3314        Err(_) => return NetError::InvalidJson.into(),
3315    };
3316    let parsed_scope: ScopeFilterJson = match serde_json::from_str(&scope_s) {
3317        Ok(v) => v,
3318        Err(_) => return NetError::InvalidJson.into(),
3319    };
3320    let filter = capability_filter_from_json(parsed_filter);
3321    let owned = scope_filter_from_json(parsed_scope);
3322    let ids = with_scope_filter(&owned, |sf| {
3323        h.inner.find_nodes_by_filter_scoped(&filter, sf)
3324    });
3325    write_json_out(&ids, out_json, out_len)
3326}
3327
3328/// JSON shape of [`CapabilityRequirement`] for the C ABI. Mirrors
3329/// the field set of the core type with snake_case keys; weights are
3330/// f32 in [0.0, 1.0] (the core clamps).
3331///
3332/// ```text
3333/// {
3334///   "filter": { … CapabilityFilter shape … },
3335///   "prefer_more_memory":     0.5,
3336///   "prefer_more_vram":       1.0,
3337///   "prefer_faster_inference": 0.0,
3338///   "prefer_loaded_models":   0.0
3339/// }
3340/// ```
3341#[derive(serde::Deserialize)]
3342struct CapabilityRequirementJson {
3343    #[serde(default)]
3344    filter: CapabilityFilterJson,
3345    #[serde(default)]
3346    prefer_more_memory: f32,
3347    #[serde(default)]
3348    prefer_more_vram: f32,
3349    #[serde(default)]
3350    prefer_faster_inference: f32,
3351    #[serde(default)]
3352    prefer_loaded_models: f32,
3353}
3354
3355fn capability_requirement_from_json(
3356    j: CapabilityRequirementJson,
3357) -> crate::adapter::net::behavior::capability::CapabilityRequirement {
3358    crate::adapter::net::behavior::capability::CapabilityRequirement::from_filter(
3359        capability_filter_from_json(j.filter),
3360    )
3361    .prefer_memory(j.prefer_more_memory)
3362    .prefer_vram(j.prefer_more_vram)
3363    .prefer_speed(j.prefer_faster_inference)
3364    .prefer_loaded(j.prefer_loaded_models)
3365}
3366
3367/// Pick the best-scoring node for a placement requirement. Writes
3368/// the winning node id to `*out_node_id` and `1` to `*out_has_match`
3369/// when a node matches; writes `0` to `*out_has_match` and leaves
3370/// `*out_node_id` untouched when no node matches. Returns `0` for
3371/// success in either case; non-zero only on input / parse error.
3372///
3373/// `requirement_json` is the JSON form documented on the private
3374/// `CapabilityRequirementJson` struct above — a `filter` object
3375/// plus four optional `prefer_*` weights in `[0.0, 1.0]`.
3376#[unsafe(no_mangle)]
3377pub unsafe extern "C" fn net_mesh_find_best_node(
3378    handle: *mut MeshNodeHandle,
3379    requirement_json: *const c_char,
3380    out_node_id: *mut u64,
3381    out_has_match: *mut c_int,
3382) -> c_int {
3383    if handle.is_null()
3384        || requirement_json.is_null()
3385        || out_node_id.is_null()
3386        || out_has_match.is_null()
3387    {
3388        return NetError::NullPointer.into();
3389    }
3390    let h = unsafe { &*handle };
3391    let _op = match h.guard.try_enter() {
3392        Some(op) => op,
3393        None => return NetError::ShuttingDown.into(),
3394    };
3395    let Some(s) = (unsafe { c_str_to_string(requirement_json) }) else {
3396        return NetError::InvalidUtf8.into();
3397    };
3398    let parsed: CapabilityRequirementJson = match serde_json::from_str(&s) {
3399        Ok(v) => v,
3400        Err(_) => return NetError::InvalidJson.into(),
3401    };
3402    let req = capability_requirement_from_json(parsed);
3403    match h.inner.find_best_node(&req) {
3404        Some(node_id) => unsafe {
3405            *out_node_id = node_id;
3406            *out_has_match = 1;
3407        },
3408        None => unsafe {
3409            *out_has_match = 0;
3410        },
3411    }
3412    0
3413}
3414
3415/// Scoped variant of [`net_mesh_find_best_node`]. Filters
3416/// candidates through `scope_json` (same shape as
3417/// [`net_mesh_find_nodes_scoped`]) before scoring; picks the
3418/// highest-scoring node within the scope-filtered set.
3419///
3420/// Same out-param contract as [`net_mesh_find_best_node`]:
3421/// `*out_has_match = 1` + `*out_node_id = winner` on hit;
3422/// `*out_has_match = 0` on no match.
3423#[unsafe(no_mangle)]
3424pub unsafe extern "C" fn net_mesh_find_best_node_scoped(
3425    handle: *mut MeshNodeHandle,
3426    requirement_json: *const c_char,
3427    scope_json: *const c_char,
3428    out_node_id: *mut u64,
3429    out_has_match: *mut c_int,
3430) -> c_int {
3431    if handle.is_null()
3432        || requirement_json.is_null()
3433        || scope_json.is_null()
3434        || out_node_id.is_null()
3435        || out_has_match.is_null()
3436    {
3437        return NetError::NullPointer.into();
3438    }
3439    let h = unsafe { &*handle };
3440    let _op = match h.guard.try_enter() {
3441        Some(op) => op,
3442        None => return NetError::ShuttingDown.into(),
3443    };
3444    let Some(req_s) = (unsafe { c_str_to_string(requirement_json) }) else {
3445        return NetError::InvalidUtf8.into();
3446    };
3447    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
3448        return NetError::InvalidUtf8.into();
3449    };
3450    let parsed_req: CapabilityRequirementJson = match serde_json::from_str(&req_s) {
3451        Ok(v) => v,
3452        Err(_) => return NetError::InvalidJson.into(),
3453    };
3454    let parsed_scope: ScopeFilterJson = match serde_json::from_str(&scope_s) {
3455        Ok(v) => v,
3456        Err(_) => return NetError::InvalidJson.into(),
3457    };
3458    let req = capability_requirement_from_json(parsed_req);
3459    let owned = scope_filter_from_json(parsed_scope);
3460    let result = with_scope_filter(&owned, |sf| h.inner.find_best_node_scoped(&req, sf));
3461    match result {
3462        Some(node_id) => unsafe {
3463            *out_node_id = node_id;
3464            *out_has_match = 1;
3465        },
3466        None => unsafe {
3467            *out_has_match = 0;
3468        },
3469    }
3470    0
3471}
3472
3473/// Normalize a GPU vendor string to its canonical lowercase form.
3474#[unsafe(no_mangle)]
3475pub unsafe extern "C" fn net_normalize_gpu_vendor(
3476    raw: *const c_char,
3477    out_json: *mut *mut c_char,
3478    out_len: *mut usize,
3479) -> c_int {
3480    if raw.is_null() || out_json.is_null() || out_len.is_null() {
3481        return NetError::NullPointer.into();
3482    }
3483    let Some(s) = (unsafe { c_str_to_string(raw) }) else {
3484        return NetError::InvalidUtf8.into();
3485    };
3486    let canonical = gpu_vendor_to_string_cap(parse_gpu_vendor_cap(&s));
3487    write_string_out(canonical.to_string(), out_json, out_len)
3488}
3489
3490#[cfg(test)]
3491mod tests {
3492    use super::*;
3493
3494    /// Regression for a cubic-flagged P2: Go-supplied JSON values
3495    /// wider than u16::MAX silently wrapped via `as u16` in
3496    /// `gpu_info_from_json` / `accelerator_from_json` /
3497    /// `hardware_from_json`, turning 65536 cores into 0. Every
3498    /// conversion site now routes through `saturating_u16_cap`.
3499    ///
3500    /// The NAPI binding has parallel end-to-end tests on
3501    /// `hardware_from_js`; the Go side verifies saturation in
3502    /// its own integration suite by round-tripping an overflow
3503    /// announcement through `announce_capabilities` (separate
3504    /// file).
3505    #[test]
3506    fn saturating_u16_cap_clamps_at_u16_max() {
3507        assert_eq!(saturating_u16_cap(0), 0);
3508        assert_eq!(saturating_u16_cap(42), 42);
3509        assert_eq!(saturating_u16_cap(u16::MAX as u32), u16::MAX);
3510        assert_eq!(saturating_u16_cap(u16::MAX as u32 + 1), u16::MAX);
3511        assert_eq!(saturating_u16_cap(u32::MAX), u16::MAX);
3512    }
3513
3514    /// Regression: `parse_modality_cap` must surface unknown
3515    /// modality strings as `None`, not silently fall back to
3516    /// `Modality::Text`. Pre-fix a typo in announce-capabilities
3517    /// like `"audoi"` advertised a Text capability the node
3518    /// didn't have; in find-nodes filters, the same typo was
3519    /// reinterpreted as `require Text` and returned the wrong
3520    /// nodes. The strict shape lets callers handle the unknown
3521    /// case explicitly (callers in this file warn-and-skip).
3522    #[test]
3523    fn parse_modality_cap_returns_none_on_unknown_strings() {
3524        // Known values still parse.
3525        for (s, expected) in [
3526            ("text", Modality::Text),
3527            ("Text", Modality::Text),
3528            ("TEXT", Modality::Text),
3529            ("image", Modality::Image),
3530            ("audio", Modality::Audio),
3531            ("video", Modality::Video),
3532            ("code", Modality::Code),
3533            ("embedding", Modality::Embedding),
3534            ("tool-use", Modality::ToolUse),
3535            ("tool_use", Modality::ToolUse),
3536            ("tooluse", Modality::ToolUse),
3537        ] {
3538            assert_eq!(
3539                parse_modality_cap(s),
3540                Some(expected),
3541                "known modality `{s}` must parse",
3542            );
3543        }
3544
3545        // Typos and unknowns return None, NOT Modality::Text.
3546        for s in ["audoi", "imageX", "vidoe", "embeding", "garbage", ""] {
3547            assert_eq!(
3548                parse_modality_cap(s),
3549                None,
3550                "unknown modality `{s}` must return None — pre-fix this \
3551                 fell back to Modality::Text, advertising a capability \
3552                 the node didn't actually have",
3553            );
3554        }
3555    }
3556
3557    /// Regression: `gpu_info_from_json` must saturate large
3558    /// `fp16_tflops_x10` values at `u16::MAX` before the f32
3559    /// conversion. Pre-fix `tf as f32` lost precision for u32
3560    /// values above 2²⁴ (f32 has a 24-bit mantissa) — the
3561    /// round-trip `u32 → f32/10.0 → with_fp16_tflops → *10.0
3562    /// as u32` could land a different `fp16_tflops_x10` than
3563    /// the operator declared. The matching saturation aligns
3564    /// with the neighboring `tops_x10` field's surface and
3565    /// keeps the round-trip exact.
3566    #[test]
3567    fn gpu_info_from_json_saturates_fp16_tflops_to_u16_max() {
3568        // A hostile or just unrealistically large value well
3569        // above the f32 precision boundary (2^24 = 16_777_216).
3570        let g = GpuJson {
3571            vendor: None,
3572            model: "test".to_string(),
3573            vram_gb: 0,
3574            compute_units: None,
3575            tensor_cores: None,
3576            fp16_tflops_x10: Some(1_000_000_000u32),
3577        };
3578        let info = gpu_info_from_json(g);
3579        // The cap is u16::MAX = 65535; the f32 round-trip back to
3580        // x10 storage must reproduce 65_535, NOT some lossily
3581        // rounded approximation of 1_000_000_000.
3582        assert_eq!(
3583            info.fp16_tflops_x10,
3584            u16::MAX as u32,
3585            "fp16_tflops_x10 must saturate at u16::MAX (65535) instead of \
3586             losing precision through the f32 round-trip; got {}",
3587            info.fp16_tflops_x10,
3588        );
3589
3590        // Sanity: a small in-range value round-trips exactly.
3591        let g_small = GpuJson {
3592            vendor: None,
3593            model: "test".to_string(),
3594            vram_gb: 0,
3595            compute_units: None,
3596            tensor_cores: None,
3597            fp16_tflops_x10: Some(425), // 42.5 TFLOPS
3598        };
3599        let info_small = gpu_info_from_json(g_small);
3600        assert_eq!(
3601            info_small.fp16_tflops_x10, 425,
3602            "small fp16_tflops_x10 must round-trip exactly"
3603        );
3604    }
3605
3606    /// Regression: `alloc_bytes` used to call `Vec::shrink_to_fit`
3607    /// and then hand the raw `(ptr, len)` to C, expecting
3608    /// `net_free_bytes` to reconstruct with
3609    /// `Vec::from_raw_parts(ptr, len, len)`. `shrink_to_fit` is not
3610    /// guaranteed to make `capacity == len`, so the reconstruction
3611    /// could UB on drop (allocator size mismatch). The fix uses
3612    /// `Layout::array::<u8>(len)` on both sides so the capacity is
3613    /// always exactly `len`.
3614    ///
3615    /// This test exercises the alloc/free round-trip across a range
3616    /// of sizes; under miri (or with the system allocator) any size
3617    /// mismatch would surface here.
3618    #[test]
3619    fn alloc_bytes_round_trip_across_sizes() {
3620        for size in [0usize, 1, 15, 16, 17, 32, 64, 1024, 8192] {
3621            let src: Vec<u8> = (0..size).map(|i| (i as u8).wrapping_mul(37)).collect();
3622            let mut ptr: *mut u8 = std::ptr::null_mut();
3623            let mut len: usize = 0;
3624            let rc = alloc_bytes(&src, &mut ptr as *mut _, &mut len as *mut _);
3625            assert_eq!(rc, 0);
3626            assert_eq!(len, size);
3627            if size == 0 {
3628                assert!(ptr.is_null());
3629            } else {
3630                assert!(!ptr.is_null());
3631                let observed = unsafe { std::slice::from_raw_parts(ptr, len) };
3632                assert_eq!(observed, &src[..]);
3633            }
3634            // Freeing with a null or zero-len must be a no-op; freeing
3635            // a real buffer must not abort or corrupt the allocator.
3636            unsafe { net_free_bytes(ptr, len) };
3637        }
3638    }
3639
3640    #[test]
3641    fn net_free_bytes_null_and_zero_len_are_noops() {
3642        // Both explicitly documented as safe no-ops.
3643        unsafe { net_free_bytes(std::ptr::null_mut(), 0) };
3644        unsafe { net_free_bytes(std::ptr::null_mut(), 42) };
3645        // A non-null pointer with len == 0 is also a no-op — we must
3646        // not try to free it, since we never allocated.
3647        let mut sentinel: u8 = 0;
3648        unsafe { net_free_bytes(&mut sentinel as *mut u8, 0) };
3649    }
3650
3651    /// `net_free_bytes` must NOT panic when called with a
3652    /// `len` larger than `isize::MAX`. Pre-fix
3653    /// `Layout::array::<u8>(len).expect(...)` panicked on such
3654    /// values (a documented `Layout::array` failure mode); the
3655    /// panic would unwind across the `extern "C"` boundary into
3656    /// any non-Rust caller (C / Go-cgo / NAPI / PyO3) — undefined
3657    /// behaviour. Now the function silently no-ops on
3658    /// `Layout::array` failure: an allocation of that size could
3659    /// not have come from this process under matching layout
3660    /// rules, so it's already memory-corruption territory and
3661    /// abandoning the free is the safest response.
3662    #[test]
3663    fn net_free_bytes_does_not_panic_on_oversized_len() {
3664        // We can't actually allocate a buffer of `isize::MAX + 1`
3665        // bytes to free; the fix's load-bearing check is that the
3666        // function reaches the `Err(_) => return` branch instead
3667        // of panicking. Pass a non-null pointer with an oversized
3668        // len; with the old `expect("byte layout")` this panics.
3669        // We use a stack sentinel as the pointer — the function
3670        // must short-circuit without touching it.
3671        let mut sentinel: u8 = 0;
3672        let ptr = &mut sentinel as *mut u8;
3673        // `usize::MAX` is well past `isize::MAX`, so
3674        // `Layout::array::<u8>(usize::MAX)` is `Err(LayoutError)`.
3675        unsafe { net_free_bytes(ptr, usize::MAX) };
3676        // If we got here without panicking, the fix is in place.
3677        // Sentinel must still be untouched (we never tried to free).
3678        assert_eq!(sentinel, 0, "sentinel must not have been written through");
3679    }
3680
3681    /// Regression for a cubic-flagged P1: `net_mesh_shutdown`
3682    /// previously returned success (0) without actually shutting
3683    /// the node down whenever `Arc::strong_count(&inner) > 1`
3684    /// (e.g. the FFI caller was holding a stream handle). The real
3685    /// shutdown was silently skipped, so background tasks kept
3686    /// draining UDP and consuming CPU. This test holds an extra
3687    /// `Arc` clone, calls `net_mesh_shutdown`, and asserts the
3688    /// shutdown flag flipped.
3689    #[test]
3690    fn net_mesh_shutdown_runs_even_with_outstanding_arc_refs() {
3691        let cfg = serde_json::json!({
3692            "bind_addr": "127.0.0.1:0",
3693            "psk_hex": "0".repeat(64),
3694        });
3695        let cfg_c = CString::new(cfg.to_string()).unwrap();
3696        let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
3697        let rc = unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) };
3698        assert_eq!(rc, 0, "net_mesh_new failed: {rc}");
3699        assert!(!out.is_null());
3700
3701        // Clone the inner Arc so strong_count > 1 — this is what a
3702        // live stream handle would look like from the guard's POV.
3703        let inner_clone = {
3704            let h = unsafe { &*out };
3705            Arc::clone(&h.inner)
3706        };
3707        assert!(Arc::strong_count(&inner_clone) >= 2);
3708        assert!(!inner_clone.is_shutdown());
3709
3710        let rc = unsafe { net_mesh_shutdown(out) };
3711        assert_eq!(rc, 0, "net_mesh_shutdown returned {rc}");
3712        assert!(
3713            inner_clone.is_shutdown(),
3714            "shutdown flag must be set even when extra Arc refs are outstanding"
3715        );
3716
3717        drop(inner_clone);
3718        // Use the production _free; it drains via HandleGuard and
3719        // takes inner. The outer box is intentionally leaked
3720        // (small per-call leak; acceptable in tests).
3721        unsafe { net_mesh_free(out) };
3722    }
3723
3724    /// Regression: BUG_REPORT.md #19 — `net_mesh_send` family
3725    /// accepted any `(MeshStreamHandle, MeshNodeHandle)` pair and
3726    /// sent through the supplied node, regardless of whether the
3727    /// stream was opened on it. The fix uses `Arc::ptr_eq` to
3728    /// require the stream's cached `_node` to match the supplied
3729    /// node handle's inner `Arc`.
3730    ///
3731    /// Build two distinct nodes via the FFI constructor (so all
3732    /// the internal fields are populated correctly), open a stream
3733    /// on the first, then verify `handles_match` accepts the
3734    /// matched pair and rejects the cross-pair.
3735    #[test]
3736    fn handles_match_rejects_stream_node_mismatch() {
3737        fn make_node_handle() -> *mut MeshNodeHandle {
3738            let cfg = serde_json::json!({
3739                "bind_addr": "127.0.0.1:0",
3740                "psk_hex": "0".repeat(64),
3741            });
3742            let cfg_c = CString::new(cfg.to_string()).unwrap();
3743            let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
3744            let rc = unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) };
3745            assert_eq!(rc, 0);
3746            assert!(!out.is_null());
3747            out
3748        }
3749
3750        let nh_a = make_node_handle();
3751        let nh_b = make_node_handle();
3752
3753        // Build a stream handle whose `_node` Arc is node_a's
3754        // inner. We can't go through `open_stream` here because
3755        // that requires an established session with the peer
3756        // (which the unit test can't synthesize), but `handles_match`
3757        // only inspects the cached `_node` Arc — the stream fields
3758        // are irrelevant to the check. Direct field init is fine
3759        // since we're in the same module.
3760        let sh_a = {
3761            let h = unsafe { &*nh_a };
3762            let node_clone: Arc<MeshNode> = Arc::clone(&h.inner);
3763            MeshStreamHandle {
3764                stream: ManuallyDrop::new(CoreStream {
3765                    peer_node_id: 0xDEAD,
3766                    stream_id: 1,
3767                    epoch: 0,
3768                    config: StreamConfig::new(),
3769                }),
3770                _node: ManuallyDrop::new(node_clone),
3771                guard: HandleGuard::new(),
3772            }
3773        };
3774
3775        // Matched pair: stream's _node == nh_a.inner — accepted.
3776        assert!(
3777            handles_match(&sh_a, unsafe { &*nh_a }),
3778            "stream from node_a + node_a handle must match"
3779        );
3780        // Mismatched pair: stream's _node != nh_b.inner — rejected.
3781        assert!(
3782            !handles_match(&sh_a, unsafe { &*nh_b }),
3783            "stream from node_a + node_b handle must be rejected (#19)"
3784        );
3785
3786        // Cleanup: take ManuallyDrop inner fields out of sh_a so
3787        // they're properly dropped (rather than leaking when sh_a
3788        // falls out of scope). Then call production _free on the
3789        // node handles (drains via HandleGuard; leaks the outer
3790        // boxes per the soundness rule — acceptable for tests).
3791        // SAFETY: sh_a was just built on this thread; no
3792        // concurrent access; ManuallyDrop fields haven't been
3793        // taken yet.
3794        unsafe {
3795            let mut sh_a = sh_a;
3796            let _ = ManuallyDrop::take(&mut sh_a.stream);
3797            let _ = ManuallyDrop::take(&mut sh_a._node);
3798        }
3799        unsafe { net_mesh_free(nh_a) };
3800        unsafe { net_mesh_free(nh_b) };
3801    }
3802
3803    /// `net_mesh_free` must be idempotent — the post-fix protocol
3804    /// does `if begin_free { ManuallyDrop::take(...) }`, so a
3805    /// second call must observe `freeing=true` and skip the take
3806    /// branch (taking again would panic since `ManuallyDrop` is
3807    /// already moved out). The `HandleGuard` core test pins the
3808    /// protocol; this test pins the per-handle wiring is correct.
3809    #[test]
3810    fn net_mesh_free_is_idempotent() {
3811        let cfg = serde_json::json!({
3812            "bind_addr": "127.0.0.1:0",
3813            "psk_hex": "0".repeat(64),
3814        });
3815        let cfg_c = CString::new(cfg.to_string()).unwrap();
3816        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
3817        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
3818        assert!(!nh.is_null());
3819
3820        unsafe { net_mesh_free(nh) };
3821        // Second free: must not panic, must not double-take the
3822        // ManuallyDrop fields, must not deallocate the (leaked)
3823        // outer box.
3824        unsafe { net_mesh_free(nh) };
3825    }
3826
3827    /// `net_identity_free` must be idempotent; same wiring check
3828    /// as `net_mesh_free_is_idempotent` for the IdentityHandle
3829    /// (which holds keypair + cache in `ManuallyDrop`).
3830    #[test]
3831    fn net_identity_free_is_idempotent() {
3832        let mut h: *mut IdentityHandle = std::ptr::null_mut();
3833        assert_eq!(unsafe { net_identity_generate(&mut h) }, 0);
3834        assert!(!h.is_null());
3835
3836        unsafe { net_identity_free(h) };
3837        // Second free: must not panic.
3838        unsafe { net_identity_free(h) };
3839    }
3840
3841    /// `net_mesh_free` racing an in-flight op via the same handle
3842    /// must wait for the op to drop its `try_enter` guard before
3843    /// taking the inner. Without the guard, `_free` would proceed
3844    /// immediately and the op's subsequent inner deref would UAF.
3845    ///
3846    /// We exercise the guard directly (rather than through a
3847    /// long-running FFI op) so the timing window is deterministic
3848    /// and not dependent on real network / IO latency. The
3849    /// worker holds a `try_enter` op until released; main thread
3850    /// calls `_free`, which post-fix must block on `begin_free`'s
3851    /// drain loop until the worker drops the op.
3852    #[test]
3853    fn net_mesh_free_waits_for_inflight_op() {
3854        use std::sync::atomic::{AtomicBool, Ordering};
3855        use std::time::{Duration, Instant};
3856
3857        let cfg = serde_json::json!({
3858            "bind_addr": "127.0.0.1:0",
3859            "psk_hex": "0".repeat(64),
3860        });
3861        let cfg_c = CString::new(cfg.to_string()).unwrap();
3862        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
3863        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
3864        assert!(!nh.is_null());
3865
3866        // Smuggle the raw pointer to the worker via usize (same
3867        // shape as cortex's `redex_file_free_waits_for_inflight_append`).
3868        let nh_addr = nh as usize;
3869        let started = Arc::new(AtomicBool::new(false));
3870        let release = Arc::new(AtomicBool::new(false));
3871        let started_w = started.clone();
3872        let release_w = release.clone();
3873
3874        let worker = std::thread::spawn(move || {
3875            let h = unsafe { &*(nh_addr as *mut MeshNodeHandle) };
3876            // Take the guard directly — every gated FFI entry
3877            // point does this internally. Holding it past the
3878            // main thread's begin_free is what we're testing.
3879            let op = h.guard.try_enter().expect("entry must succeed pre-free");
3880            started_w.store(true, Ordering::SeqCst);
3881            while !release_w.load(Ordering::SeqCst) {
3882                std::thread::sleep(Duration::from_millis(1));
3883            }
3884            drop(op);
3885        });
3886
3887        // Wait for the worker to enter the op.
3888        while !started.load(Ordering::SeqCst) {
3889            std::thread::yield_now();
3890        }
3891
3892        // Schedule release ~50ms out so begin_free has time to
3893        // observe `active_ops > 0` and enter its drain loop.
3894        let release_clone = release.clone();
3895        std::thread::spawn(move || {
3896            std::thread::sleep(Duration::from_millis(50));
3897            release_clone.store(true, Ordering::SeqCst);
3898        });
3899
3900        // _free MUST block until the worker drops its op.
3901        let t0 = Instant::now();
3902        unsafe { net_mesh_free(nh) };
3903        let elapsed = t0.elapsed();
3904        assert!(
3905            elapsed >= Duration::from_millis(40),
3906            "net_mesh_free returned in {:?} — pre-fix it would have proceeded \
3907             immediately and the worker's subsequent op would UAF",
3908            elapsed,
3909        );
3910        worker.join().unwrap();
3911    }
3912
3913    /// Post-free `net_mesh_stream_stats` must bail with
3914    /// ShuttingDown rather than touching the freed
3915    /// `inner: ManuallyDrop<Arc<MeshNode>>`. Without the guard,
3916    /// the function would do `&*node_handle;
3917    /// h.inner.stream_stats(...)` and race UAF against
3918    /// `net_mesh_free`.
3919    #[test]
3920    fn net_mesh_stream_stats_returns_shutting_down_after_free() {
3921        let cfg = serde_json::json!({
3922            "bind_addr": "127.0.0.1:0",
3923            "psk_hex": "0".repeat(64),
3924        });
3925        let cfg_c = CString::new(cfg.to_string()).unwrap();
3926        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
3927        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
3928        assert!(!nh.is_null());
3929
3930        // Free first; subsequent stream_stats must bail before
3931        // touching the taken-out inner.
3932        unsafe { net_mesh_free(nh) };
3933
3934        let mut out_json: *mut c_char = std::ptr::null_mut();
3935        let mut out_len: usize = 0;
3936        let rc = unsafe { net_mesh_stream_stats(nh, 0xDEAD, 1, &mut out_json, &mut out_len) };
3937        assert_eq!(
3938            rc,
3939            NetError::ShuttingDown as c_int,
3940            "post-free stream_stats must surface ShuttingDown (got {rc})",
3941        );
3942        assert!(
3943            out_json.is_null(),
3944            "no payload may be written after the guard fires",
3945        );
3946    }
3947
3948    /// Post-free `net_identity_issue_token` must bail with
3949    /// ShuttingDown rather than borrowing the freed keypair
3950    /// (which lives in `ManuallyDrop` and is taken out by
3951    /// `net_identity_free`).
3952    #[test]
3953    fn net_identity_issue_token_returns_shutting_down_after_free() {
3954        let mut signer: *mut IdentityHandle = std::ptr::null_mut();
3955        assert_eq!(unsafe { net_identity_generate(&mut signer) }, 0);
3956        assert!(!signer.is_null());
3957        unsafe { net_identity_free(signer) };
3958
3959        // Well-formed inputs (so we reach the guard rather than
3960        // bailing on parse).
3961        let subject = [0u8; 32];
3962        let scope = CString::new("[\"publish\"]").unwrap();
3963        let channel = CString::new("test-channel").unwrap();
3964        let mut out_token: *mut u8 = std::ptr::null_mut();
3965        let mut out_token_len: usize = 0;
3966        let rc = unsafe {
3967            net_identity_issue_token(
3968                signer,
3969                subject.as_ptr(),
3970                subject.len(),
3971                scope.as_ptr(),
3972                channel.as_ptr(),
3973                60,
3974                0,
3975                &mut out_token,
3976                &mut out_token_len,
3977            )
3978        };
3979        assert_eq!(
3980            rc,
3981            NetError::ShuttingDown as c_int,
3982            "post-free issue_token must surface ShuttingDown (got {rc})",
3983        );
3984        assert!(out_token.is_null(), "no token bytes may be allocated");
3985    }
3986
3987    /// Post-free `net_delegate_token` must bail with ShuttingDown
3988    /// rather than borrowing the freed signer keypair. The parent
3989    /// token must validate first (parse before guard), so we
3990    /// issue a real one from a live signer, then free that signer
3991    /// and reuse it as the delegating signer.
3992    #[test]
3993    fn net_delegate_token_returns_shutting_down_after_free() {
3994        let mut signer: *mut IdentityHandle = std::ptr::null_mut();
3995        assert_eq!(unsafe { net_identity_generate(&mut signer) }, 0);
3996        assert!(!signer.is_null());
3997
3998        // Issue a real parent token while signer is alive.
3999        let subject = [0u8; 32];
4000        let scope = CString::new("[\"publish\",\"delegate\"]").unwrap();
4001        let channel = CString::new("test-channel").unwrap();
4002        let mut parent_bytes: *mut u8 = std::ptr::null_mut();
4003        let mut parent_len: usize = 0;
4004        assert_eq!(
4005            unsafe {
4006                net_identity_issue_token(
4007                    signer,
4008                    subject.as_ptr(),
4009                    subject.len(),
4010                    scope.as_ptr(),
4011                    channel.as_ptr(),
4012                    60,
4013                    1,
4014                    &mut parent_bytes,
4015                    &mut parent_len,
4016                )
4017            },
4018            0,
4019        );
4020        assert!(!parent_bytes.is_null());
4021
4022        // Now free the signer and try to delegate using it.
4023        unsafe { net_identity_free(signer) };
4024
4025        let new_subject = [1u8; 32];
4026        let restricted = CString::new("[\"publish\"]").unwrap();
4027        let mut child_bytes: *mut u8 = std::ptr::null_mut();
4028        let mut child_len: usize = 0;
4029        let rc = unsafe {
4030            net_delegate_token(
4031                signer,
4032                parent_bytes,
4033                parent_len,
4034                new_subject.as_ptr(),
4035                new_subject.len(),
4036                restricted.as_ptr(),
4037                &mut child_bytes,
4038                &mut child_len,
4039            )
4040        };
4041        assert_eq!(
4042            rc,
4043            NetError::ShuttingDown as c_int,
4044            "post-free delegate_token must surface ShuttingDown (got {rc})",
4045        );
4046        assert!(child_bytes.is_null(), "no child token may be allocated");
4047
4048        // Cleanup: free the parent token bytes.
4049        unsafe { net_free_bytes(parent_bytes, parent_len) };
4050    }
4051
4052    #[test]
4053    fn hardware_from_json_saturates_overflow_cpu_fields() {
4054        // 70_000 > u16::MAX (65_535). Pre-fix: 70_000 as u16 = 4464.
4055        // Post-fix: saturates to 65_535.
4056        let h = HardwareJson {
4057            cpu_cores: Some(70_000),
4058            cpu_threads: Some(200_000),
4059            memory_gb: None,
4060            gpu: None,
4061            additional_gpus: Vec::new(),
4062            storage_gb: None,
4063            network_gbps: None,
4064            accelerators: Vec::new(),
4065        };
4066        let hw = hardware_from_json(h);
4067        assert_eq!(hw.cpu_cores, u16::MAX);
4068        assert_eq!(hw.cpu_threads, u16::MAX);
4069    }
4070
4071    /// A C caller passing `(size_t)-1` as `len` to the token-parsing
4072    /// FFI entry points previously triggered immediate UB in
4073    /// `slice::from_raw_parts` (which requires `len <= isize::MAX`).
4074    /// The guard must short-circuit with a typed error before the
4075    /// dangling pointer is dereferenced. The sentinel pointer is
4076    /// never read because the size check fires first.
4077    #[test]
4078    fn token_entry_points_reject_oversize_len() {
4079        let invalid_json: c_int = NetError::InvalidJson.into();
4080        let mut sentinel: u8 = 0;
4081        let token = &mut sentinel as *mut u8 as *const u8;
4082
4083        let mut out_json: *mut c_char = std::ptr::null_mut();
4084        let mut out_len: usize = 0;
4085        assert_eq!(
4086            unsafe { net_parse_token(token, usize::MAX, &mut out_json, &mut out_len) },
4087            invalid_json,
4088        );
4089        assert!(out_json.is_null());
4090
4091        let mut out_ok: c_int = -42;
4092        assert_eq!(
4093            unsafe { net_verify_token(token, usize::MAX, &mut out_ok) },
4094            invalid_json,
4095        );
4096
4097        let mut out_expired: c_int = -42;
4098        assert_eq!(
4099            unsafe { net_token_is_expired(token, usize::MAX, &mut out_expired) },
4100            invalid_json,
4101        );
4102
4103        assert_eq!(
4104            sentinel, 0,
4105            "sentinel must not be touched: the length guard fires before any deref"
4106        );
4107    }
4108}
4109
4110#[cfg(all(test, not(feature = "nat-traversal")))]
4111mod nat_traversal_stub_tests {
4112    //! Regression coverage for cubic-flagged P1 Bug L: the Go /
4113    //! NAPI / PyO3 bindings unconditionally link against the
4114    //! `net_mesh_nat_type` / `net_mesh_connect_direct` / ...
4115    //! symbols. Without these stubs, a cdylib built without
4116    //! `--features nat-traversal` failed at dlopen with a missing-
4117    //! symbol error, contradicting the binding docs' promise of
4118    //! `ErrTraversalUnsupported` at runtime.
4119    //!
4120    //! Each test here asserts the stub resolves *and* returns
4121    //! [`super::NET_ERR_TRAVERSAL_UNSUPPORTED`] (-137) — the exact
4122    //! value the Go / NAPI / PyO3 translation layers map to their
4123    //! respective `Unsupported` sentinels.
4124    //!
4125    //! Only compiled in the no-feature build; the feature-on path
4126    //! has different semantics (real NAT-traversal work) tested
4127    //! elsewhere.
4128    use super::*;
4129    use std::ptr;
4130
4131    #[test]
4132    fn nat_type_stub_returns_unsupported() {
4133        let mut out_str: *mut c_char = ptr::null_mut();
4134        let mut out_len: usize = 0;
4135        let code = net_mesh_nat_type(ptr::null_mut(), &mut out_str, &mut out_len);
4136        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4137    }
4138
4139    #[test]
4140    fn reflex_addr_stub_returns_unsupported() {
4141        let mut out_str: *mut c_char = ptr::null_mut();
4142        let mut out_len: usize = 0;
4143        let code = net_mesh_reflex_addr(ptr::null_mut(), &mut out_str, &mut out_len);
4144        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4145    }
4146
4147    #[test]
4148    fn peer_nat_type_stub_returns_unsupported() {
4149        let mut out_str: *mut c_char = ptr::null_mut();
4150        let mut out_len: usize = 0;
4151        let code = net_mesh_peer_nat_type(ptr::null_mut(), 0, &mut out_str, &mut out_len);
4152        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4153    }
4154
4155    #[test]
4156    fn probe_reflex_stub_returns_unsupported() {
4157        let mut out_str: *mut c_char = ptr::null_mut();
4158        let mut out_len: usize = 0;
4159        let code = net_mesh_probe_reflex(ptr::null_mut(), 0, &mut out_str, &mut out_len);
4160        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4161    }
4162
4163    #[test]
4164    fn reclassify_nat_stub_returns_unsupported() {
4165        let code = net_mesh_reclassify_nat(ptr::null_mut());
4166        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4167    }
4168
4169    #[test]
4170    fn traversal_stats_stub_returns_unsupported() {
4171        let mut a: u64 = 0;
4172        let mut b: u64 = 0;
4173        let mut c: u64 = 0;
4174        let code = net_mesh_traversal_stats(ptr::null_mut(), &mut a, &mut b, &mut c);
4175        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4176    }
4177
4178    #[test]
4179    fn connect_direct_stub_returns_unsupported() {
4180        let code = net_mesh_connect_direct(ptr::null_mut(), 0, ptr::null(), 0);
4181        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4182    }
4183
4184    #[test]
4185    fn set_reflex_override_stub_returns_unsupported() {
4186        let code = net_mesh_set_reflex_override(ptr::null_mut(), ptr::null());
4187        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4188    }
4189
4190    #[test]
4191    fn clear_reflex_override_stub_returns_unsupported() {
4192        let code = net_mesh_clear_reflex_override(ptr::null_mut());
4193        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
4194    }
4195
4196    /// Pins the constant itself. If anyone ever renumbers
4197    /// `NET_ERR_TRAVERSAL_UNSUPPORTED`, every Go / NAPI / PyO3
4198    /// binding's error translation silently breaks — the stubs
4199    /// return the new value but the mapping layers are hardcoded
4200    /// to -137.
4201    #[test]
4202    fn unsupported_code_is_stable() {
4203        assert_eq!(NET_ERR_TRAVERSAL_UNSUPPORTED, -137);
4204    }
4205
4206    /// Repro for the failing Go `TestHardwareAndGpuFilter_Matches`:
4207    /// parse the exact JSON the Go binding marshals, convert via
4208    /// the FFI helpers, then verify the GpuVendor lands as Nvidia.
4209    #[test]
4210    fn capability_set_from_go_marshal_preserves_gpu_vendor() {
4211        let json = r#"{"hardware":{"cpu_cores":16,"memory_gb":64,"gpu":{"vendor":"nvidia","model":"h100","vram_gb":80}},"tags":["gpu"]}"#;
4212        let parsed: CapabilitySetJson = serde_json::from_str(json).expect("JSON should parse");
4213        let caps = capability_set_from_json(parsed);
4214        // Phase A.5.5: read through views() so the test asserts
4215        // the projection — the same surface every consumer sees
4216        // post-Phase-A.5.N when typed-struct fields are removed.
4217        let views = caps.views();
4218        assert_eq!(
4219            views.hardware().gpu_vendor(),
4220            Some(super::GpuVendor::Nvidia),
4221            "vendor lost in conversion"
4222        );
4223        assert_eq!(views.hardware().memory_gb, 64);
4224        assert_eq!(views.hardware().total_vram_gb(), 80);
4225        assert!(caps.has_tag("gpu"));
4226    }
4227
4228    /// Regression: BUG_REPORT.md #15 — `collect_payloads` previously
4229    /// dereferenced every per-entry pointer without a null check, so a C
4230    /// caller passing an array containing a null entry produced UB on
4231    /// `from_raw_parts(null, len)`. The fix returns `None` for any null
4232    /// pointer with non-zero length so the caller can return
4233    /// `NetError::NullPointer`. A null pointer with length 0 is treated
4234    /// as an empty payload (allowed because the pointer is never
4235    /// dereferenced).
4236    #[test]
4237    fn collect_payloads_rejects_null_entry_with_nonzero_length() {
4238        let buf_a = b"hello".as_slice();
4239        let buf_b = b"world".as_slice();
4240        let ptrs: [*const u8; 3] = [buf_a.as_ptr(), std::ptr::null(), buf_b.as_ptr()];
4241        let lens: [usize; 3] = [buf_a.len(), 4, buf_b.len()];
4242
4243        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 3) };
4244        assert!(
4245            result.is_none(),
4246            "null entry with non-zero length must reject the whole batch"
4247        );
4248    }
4249
4250    #[test]
4251    fn collect_payloads_allows_null_entry_with_zero_length() {
4252        let buf_a = b"hello".as_slice();
4253        let ptrs: [*const u8; 2] = [buf_a.as_ptr(), std::ptr::null()];
4254        let lens: [usize; 2] = [buf_a.len(), 0];
4255
4256        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 2) }
4257            .expect("zero-length null is treated as empty payload");
4258        assert_eq!(result.len(), 2);
4259        assert_eq!(&result[0][..], b"hello");
4260        assert!(result[1].is_empty());
4261    }
4262
4263    #[test]
4264    fn collect_payloads_happy_path() {
4265        let buf_a = b"abc".as_slice();
4266        let buf_b = b"defg".as_slice();
4267        let ptrs: [*const u8; 2] = [buf_a.as_ptr(), buf_b.as_ptr()];
4268        let lens: [usize; 2] = [buf_a.len(), buf_b.len()];
4269
4270        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 2) }
4271            .expect("non-null entries should succeed");
4272        assert_eq!(result.len(), 2);
4273        assert_eq!(&result[0][..], b"abc");
4274        assert_eq!(&result[1][..], b"defg");
4275    }
4276}