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