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