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