Skip to main content

net/ffi/
mesh.rs

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