Skip to main content

net/ffi/
mesh.rs

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