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