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