Skip to main content

net/ffi/
mesh.rs

1//! C FFI bindings for the encrypted-UDP mesh transport.
2//!
3//! Surface targeted at the Go SDK. Mirrors the Rust SDK's `Mesh`
4//! type (not the full core `MeshNode`) — just the common path:
5//! handshake, per-peer streams, channels, shard receive.
6//!
7//! Everything crosses the boundary as:
8//!
9//! - Opaque handles (`*mut T`) freed via dedicated `_free` functions.
10//! - Scalar ids as `u64`.
11//! - Everything else as JSON strings allocated with
12//!   `CString::into_raw`, freed by the caller via `net_free_string`.
13//!
14//! Handshake + per-peer sends are async on the core side; the FFI
15//! drives them via a shared `tokio::runtime::Runtime` (lazy OnceLock)
16//! identical to the one used by `ffi/cortex.rs`.
17//!
18//! # Safety
19//!
20//! Every entry point in this module is `unsafe extern "C"` and shares
21//! the same caller-side contract:
22//!
23//! - Opaque handle pointers are valid, properly aligned, produced by
24//!   this crate's matching constructor (`Box::into_raw` inside the
25//!   FFI surface), and not used after their `_free` counterpart (or
26//!   `net_shutdown`) has returned. Foreign-allocated pointers will UB
27//!   when consumed by `Box::from_raw` in the corresponding `_free`.
28//! - String pointers are non-null, NUL-terminated, and point to valid
29//!   UTF-8 (or, where documented, to opaque bytes paired with an
30//!   explicit length argument).
31//! - Out-parameter pointers (`*mut T`) are non-null and writable for
32//!   the lifetime of the call.
33//! - Buffer / length pairs accurately describe the producer-allocated
34//!   memory the callee may read or write.
35//!
36//! These are the same invariants `include/net.h` documents for C
37//! callers. The per-call `# Safety` rustdoc is intentionally
38//! suppressed (`clippy::missing_safety_doc`) and per-block `// SAFETY:`
39//! comments are gated by the module-level `#![expect]` below — every
40//! `unsafe { }` in this file inherits the contract above, and inlining
41//! the same wording at each of the ~120 call sites adds noise without
42//! signal.
43#![allow(clippy::missing_safety_doc)]
44#![expect(
45    clippy::undocumented_unsafe_blocks,
46    reason = "module-wide FFI safety contract documented in the # Safety preamble above"
47)]
48#![expect(
49    clippy::multiple_unsafe_ops_per_block,
50    reason = "FFI entry points routinely deref + write to multiple out-parameter fields under the same caller contract; splitting per-op would obscure the single boundary-cross"
51)]
52
53use std::ffi::{c_char, c_int, CStr, CString};
54use std::mem::ManuallyDrop;
55use std::sync::Arc;
56
57use bytes::Bytes;
58use serde::{Deserialize, Serialize};
59use tokio::runtime::Runtime;
60
61use crate::adapter::net::identity::{
62    EntityId, PermissionToken, TokenCache, TokenError as CoreTokenError, TokenScope,
63};
64use crate::adapter::net::{
65    ChannelConfig as InnerChannelConfig, ChannelConfigRegistry, ChannelHash, ChannelId,
66    ChannelName as InnerChannelName, ChannelPublisher, EntityKeypair, MeshNode, MeshNodeConfig,
67    OnFailure as InnerOnFailure, PublishConfig as InnerPublishConfig,
68    PublishReport as InnerPublishReport, Reliability, Stream as CoreStream, StreamConfig,
69    StreamError, Visibility as InnerVisibility, DEFAULT_STREAM_WINDOW_BYTES,
70};
71use crate::adapter::net::{SubnetId, SubnetPolicy, SubnetRule};
72use crate::adapter::Adapter;
73use crate::error::AdapterError;
74
75use super::handle_guard::{HandleGuard, FFI_HANDLE_FREE_DEADLINE};
76use super::NetError;
77
78// =========================================================================
79// Mesh-specific error codes. Continues the -100..-99 range used by
80// `ffi/cortex.rs`. The Go layer maps these to typed sentinels.
81// =========================================================================
82
83pub(crate) const NET_ERR_MESH_INIT: c_int = -110;
84pub(crate) const NET_ERR_MESH_HANDSHAKE: c_int = -111;
85pub(crate) const NET_ERR_MESH_BACKPRESSURE: c_int = -112;
86pub(crate) const NET_ERR_MESH_NOT_CONNECTED: c_int = -113;
87pub(crate) const NET_ERR_MESH_TRANSPORT: c_int = -114;
88pub(crate) const NET_ERR_CHANNEL: c_int = -115;
89pub(crate) const NET_ERR_CHANNEL_AUTH: c_int = -116;
90
91// Identity + token error codes. Block -120..-129 mirrors the
92// `"identity: ..."` / `"token: <kind>"` prefix convention used by
93// PyO3 and NAPI; each `kind` gets its own integer so Go callers can
94// `errors.Is(err, net.ErrTokenExpired)` without parsing strings.
95pub(crate) const NET_ERR_IDENTITY: c_int = -120;
96pub(crate) const NET_ERR_TOKEN_INVALID_FORMAT: c_int = -121;
97pub(crate) const NET_ERR_TOKEN_INVALID_SIGNATURE: c_int = -122;
98pub(crate) const NET_ERR_TOKEN_EXPIRED: c_int = -123;
99pub(crate) const NET_ERR_TOKEN_NOT_YET_VALID: c_int = -124;
100pub(crate) const NET_ERR_TOKEN_DELEGATION_EXHAUSTED: c_int = -125;
101pub(crate) const NET_ERR_TOKEN_DELEGATION_NOT_ALLOWED: c_int = -126;
102pub(crate) const NET_ERR_TOKEN_NOT_AUTHORIZED: c_int = -127;
103
104// NAT-traversal error codes. Block -130..-139 — one integer per
105// `TraversalError::kind()` so Go callers can
106// `errors.Is(err, net.ErrTraversalPunchFailed)` without parsing
107// strings, matching the token-error pattern above. Framing (plan
108// §5): every `TraversalError` represents a missed *optimization*,
109// not a connectivity failure — the routed-handshake path is
110// always available. See `TraversalError` docs for per-variant
111// semantics.
112// Per-variant traversal error codes. Gated on the feature
113// because they're only referenced by `traversal_err_to_code`,
114// which only compiles with the feature on. `NET_ERR_TRAVERSAL_UNSUPPORTED`
115// below is unconditional — the no-feature stubs need it.
116#[cfg(feature = "nat-traversal")]
117pub(crate) const NET_ERR_TRAVERSAL_REFLEX_TIMEOUT: c_int = -130;
118#[cfg(feature = "nat-traversal")]
119pub(crate) const NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE: c_int = -131;
120#[cfg(feature = "nat-traversal")]
121pub(crate) const NET_ERR_TRAVERSAL_TRANSPORT: c_int = -132;
122#[cfg(feature = "nat-traversal")]
123pub(crate) const NET_ERR_TRAVERSAL_RENDEZVOUS_NO_RELAY: c_int = -133;
124#[cfg(feature = "nat-traversal")]
125pub(crate) const NET_ERR_TRAVERSAL_RENDEZVOUS_REJECTED: c_int = -134;
126#[cfg(feature = "nat-traversal")]
127pub(crate) const NET_ERR_TRAVERSAL_PUNCH_FAILED: c_int = -135;
128#[cfg(feature = "nat-traversal")]
129pub(crate) const NET_ERR_TRAVERSAL_PORT_MAP_UNAVAILABLE: c_int = -136;
130// Unconditional — the `#[cfg(not(feature = "nat-traversal"))]`
131// FFI stubs below return this so the Go / NAPI / PyO3 bindings
132// surface `ErrTraversalUnsupported` when built against a cdylib
133// without the feature, rather than failing at dlopen with a
134// missing-symbol error.
135pub(crate) const NET_ERR_TRAVERSAL_UNSUPPORTED: c_int = -137;
136
137#[cfg(feature = "nat-traversal")]
138fn traversal_err_to_code(e: &crate::adapter::net::traversal::TraversalError) -> c_int {
139    use crate::adapter::net::traversal::TraversalError;
140    match e {
141        TraversalError::ReflexTimeout => NET_ERR_TRAVERSAL_REFLEX_TIMEOUT,
142        TraversalError::PeerNotReachable => NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE,
143        TraversalError::Transport(_) => NET_ERR_TRAVERSAL_TRANSPORT,
144        TraversalError::RendezvousNoRelay => NET_ERR_TRAVERSAL_RENDEZVOUS_NO_RELAY,
145        TraversalError::RendezvousRejected(_) => NET_ERR_TRAVERSAL_RENDEZVOUS_REJECTED,
146        TraversalError::PunchFailed => NET_ERR_TRAVERSAL_PUNCH_FAILED,
147        TraversalError::PortMapUnavailable => NET_ERR_TRAVERSAL_PORT_MAP_UNAVAILABLE,
148        TraversalError::Unsupported => NET_ERR_TRAVERSAL_UNSUPPORTED,
149    }
150}
151
152/// Stable string form of a `NatClass`. Same vocabulary as the
153/// NAPI / PyO3 bindings — callers branch on
154/// `"open" | "cone" | "symmetric" | "unknown"`.
155#[cfg(feature = "nat-traversal")]
156fn nat_class_to_str(class: crate::adapter::net::traversal::classify::NatClass) -> &'static str {
157    use crate::adapter::net::traversal::classify::NatClass;
158    match class {
159        NatClass::Open => "open",
160        NatClass::Cone => "cone",
161        NatClass::Symmetric => "symmetric",
162        NatClass::Unknown => "unknown",
163    }
164}
165
166fn token_err_to_code(e: &CoreTokenError) -> c_int {
167    match e {
168        CoreTokenError::InvalidFormat => NET_ERR_TOKEN_INVALID_FORMAT,
169        CoreTokenError::InvalidSignature => NET_ERR_TOKEN_INVALID_SIGNATURE,
170        CoreTokenError::Expired => NET_ERR_TOKEN_EXPIRED,
171        CoreTokenError::NotYetValid => NET_ERR_TOKEN_NOT_YET_VALID,
172        CoreTokenError::DelegationExhausted => NET_ERR_TOKEN_DELEGATION_EXHAUSTED,
173        CoreTokenError::DelegationNotAllowed => NET_ERR_TOKEN_DELEGATION_NOT_ALLOWED,
174        CoreTokenError::NotAuthorized => NET_ERR_TOKEN_NOT_AUTHORIZED,
175        // A revoked chain link is an authorization failure from the
176        // caller's perspective — the credential was valid-shaped but
177        // is no longer honored. Same code as `NotAuthorized`; the
178        // `Display` message distinguishes the cause.
179        CoreTokenError::Revoked => NET_ERR_TOKEN_NOT_AUTHORIZED,
180        // Maps to `NET_ERR_IDENTITY` since a public-only keypair
181        // is fundamentally an identity-availability issue, not a
182        // token-content issue. The error message in `Display`
183        // makes the cause clear to the caller.
184        CoreTokenError::ReadOnly => NET_ERR_IDENTITY,
185        // A zero-TTL request is a malformed token-issue
186        // input. Routes to `NET_ERR_TOKEN_INVALID_FORMAT` (the
187        // closest existing semantic — invalid input shape) so
188        // the C/Go header surface stays unchanged. The Display
189        // message ("token TTL must be > 0 seconds") tells the
190        // caller exactly what was wrong.
191        CoreTokenError::ZeroTtl => NET_ERR_TOKEN_INVALID_FORMAT,
192        // An over-long TTL is another malformed token-issue input
193        // (`duration_secs` past the hard ceiling). Same mapping as
194        // `ZeroTtl`; the `Display` message names the limit.
195        CoreTokenError::TtlTooLong => NET_ERR_TOKEN_INVALID_FORMAT,
196    }
197}
198
199// =========================================================================
200// Shared utilities
201// =========================================================================
202
203/// Shared tokio runtime. One per process, lazy-initialized.
204///
205/// On `tokio::Builder::build()` failure (worker-thread
206/// `pthread_create` failure under `RLIMIT_NPROC` / container
207/// limits / memory pressure) we `eprintln! + std::process::abort()`
208/// rather than panic. `abort` is `extern "C"`-safe (terminates
209/// rather than unwinds), so the failure cannot escape across the
210/// surrounding `extern "C"` FFI frame into C / Go-cgo / NAPI /
211/// PyO3 callers — that would be undefined behaviour. A daemon
212/// that can't construct its async runtime is dead in the water,
213/// so termination is the appropriate response.
214fn runtime() -> &'static Arc<Runtime> {
215    use std::sync::OnceLock;
216    static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
217    RT.get_or_init(|| {
218        match tokio::runtime::Builder::new_multi_thread()
219            .enable_all()
220            .build()
221        {
222            Ok(rt) => Arc::new(rt),
223            Err(e) => {
224                eprintln!(
225                    "FATAL: mesh FFI tokio runtime build failure ({e:?}); aborting to avoid panic across the FFI boundary"
226                );
227                std::process::abort();
228            }
229        }
230    })
231}
232
233/// `block_on(...)` wrapper that aborts on runtime-in-runtime
234/// rather than panicking across the FFI boundary.
235///
236/// Calling `Runtime::block_on` from a thread that already holds a
237/// tokio runtime context panics with "Cannot start a runtime from
238/// within a runtime". The cortex / mesh FFI functions are
239/// `extern "C"`, so the panic would unwind across cgo / N-API / cffi
240/// — undefined behavior. The check costs one TLS lookup
241/// (`Handle::try_current`) per FFI call, which is negligible against
242/// the work the FFI is about to do (network I/O, JSON parsing,
243/// channel operations). Common-case callers (C / Go / Python without
244/// an embedding Rust runtime) hit the fast path; embedded-Rust
245/// callers who violate the contract get a clean abort with a
246/// diagnosable message instead of UB.
247/// Crate-internal: `tokio::Runtime::block_on` against the
248/// shared mesh-FFI runtime. Aborts on runtime-in-runtime so a
249/// stray sync-from-async call doesn't panic across the FFI
250/// boundary. Re-used by `ffi::aggregator` and any future FFI
251/// module that needs the same runtime semantics.
252pub(super) fn block_on<F: std::future::Future>(future: F) -> F::Output {
253    if tokio::runtime::Handle::try_current().is_ok() {
254        eprintln!(
255            "FATAL: mesh FFI called from inside a tokio runtime context; \
256             aborting to avoid runtime-in-runtime panic across the FFI boundary"
257        );
258        std::process::abort();
259    }
260    runtime().block_on(future)
261}
262
263/// The output borrow's lifetime is tied (via Rust's elision rules)
264/// to the input reference's lifetime, so the caller cannot pick
265/// `'static` and produce a dangling borrow. The borrow lives only
266/// as long as the local stack frame holding the pointer — which is
267/// the caller's responsibility to keep valid for the duration of
268/// any resulting `&str` use, but no longer. Compare
269/// `cortex.rs::c_str_to_owned` which sidesteps the issue entirely
270/// by returning `Option<String>`.
271///
272/// Returns an OWNED `String` (not a borrowed `&str` tied to the C
273/// buffer). The previous `Option<&str>` signature was a soundness
274/// trap: lifetime elision on `&*const c_char` bound the returned
275/// `&str` to the local pointer reference's stack slot rather than
276/// to the underlying C buffer, so a future refactor that moved the
277/// result into `tokio::spawn(async move { ... })` would compile
278/// silently and hand a dangling pointer to the spawned task. The
279/// owned-`String` shape removes the hazard at the cost of one
280/// allocation per call, which is acceptable on FFI entry paths.
281///
282/// # Safety
283/// Caller must ensure `p` is null or points to a NUL-terminated C
284/// string valid at least until this function returns.
285#[inline]
286pub(super) unsafe fn c_str_to_string(p: *const c_char) -> Option<String> {
287    if p.is_null() {
288        return None;
289    }
290    CStr::from_ptr(p).to_str().ok().map(str::to_owned)
291}
292
293/// Null-check `out_ptr` and `out_len` before writing through them.
294/// The helper is callable from any FFI boundary; a future caller
295/// forgetting to check produced UB (write through null). Returns
296/// `NetError::NullPointer` so the FFI caller can distinguish "I
297/// forgot to provide outputs" from "the operation failed."
298fn write_json_out<T: Serialize>(
299    value: &T,
300    out_ptr: *mut *mut c_char,
301    out_len: *mut usize,
302) -> c_int {
303    if out_ptr.is_null() || out_len.is_null() {
304        return NetError::NullPointer.into();
305    }
306    let Ok(s) = serde_json::to_string(value) else {
307        return NetError::Unknown.into();
308    };
309    let len = s.len();
310    let Ok(cs) = CString::new(s) else {
311        return NetError::Unknown.into();
312    };
313    unsafe {
314        *out_ptr = cs.into_raw();
315        *out_len = len;
316    }
317    0
318}
319
320pub(super) fn write_string_out(s: String, out_ptr: *mut *mut c_char, out_len: *mut usize) -> c_int {
321    if out_ptr.is_null() || out_len.is_null() {
322        return NetError::NullPointer.into();
323    }
324    let len = s.len();
325    let Ok(cs) = CString::new(s) else {
326        return NetError::Unknown.into();
327    };
328    unsafe {
329        *out_ptr = cs.into_raw();
330        *out_len = len;
331    }
332    0
333}
334
335fn adapter_err_to_code(err: &AdapterError) -> c_int {
336    match err {
337        AdapterError::Connection(_) => NET_ERR_MESH_HANDSHAKE,
338        _ => NET_ERR_MESH_TRANSPORT,
339    }
340}
341
342fn stream_err_to_code(err: &StreamError) -> c_int {
343    match err {
344        StreamError::Backpressure => NET_ERR_MESH_BACKPRESSURE,
345        StreamError::NotConnected => NET_ERR_MESH_NOT_CONNECTED,
346        StreamError::Transport(_) => NET_ERR_MESH_TRANSPORT,
347    }
348}
349
350// =========================================================================
351// MeshNode
352// =========================================================================
353
354#[derive(Deserialize)]
355struct SubnetPolicyJson {
356    #[serde(default)]
357    rules: Vec<SubnetRuleJson>,
358}
359
360#[derive(Deserialize)]
361struct SubnetRuleJson {
362    tag_prefix: String,
363    level: u32,
364    #[serde(default)]
365    values: std::collections::HashMap<String, u32>,
366}
367
368fn u8_from_u32(value: u32) -> Option<u8> {
369    if value > 255 {
370        None
371    } else {
372        Some(value as u8)
373    }
374}
375
376fn subnet_id_from_json(levels: Vec<u32>) -> Option<SubnetId> {
377    if levels.is_empty() || levels.len() > 4 {
378        return None;
379    }
380    let mut bytes = [0u8; 4];
381    for (i, raw) in levels.iter().enumerate() {
382        bytes[i] = u8_from_u32(*raw)?;
383    }
384    Some(SubnetId::new(&bytes[..levels.len()]))
385}
386
387fn subnet_policy_from_json(p: SubnetPolicyJson) -> Option<SubnetPolicy> {
388    let mut policy = SubnetPolicy::new();
389    for rule_json in p.rules {
390        let level = u8_from_u32(rule_json.level)?;
391        if level > 3 {
392            return None;
393        }
394        let mut rule = SubnetRule::new(rule_json.tag_prefix, level);
395        for (tag_value, raw_val) in rule_json.values {
396            let v = u8_from_u32(raw_val)?;
397            // `SubnetRule::map` panics when `v == 0` — zero is
398            // reserved by the core as "unmatched / no restriction"
399            // and must not appear as an explicit mapping. Reject
400            // at the FFI boundary so Go callers surface a clean
401            // `NET_ERR_MESH_INIT` instead of a cdylib abort.
402            if v == 0 {
403                return None;
404            }
405            rule = rule.map(tag_value, v);
406        }
407        policy = policy.add_rule(rule);
408    }
409    Some(policy)
410}
411
412#[derive(Deserialize)]
413struct MeshNewConfig {
414    bind_addr: String,
415    /// Hex-encoded 32-byte pre-shared key.
416    psk_hex: String,
417    heartbeat_ms: Option<u64>,
418    session_timeout_ms: Option<u64>,
419    num_shards: Option<u16>,
420    /// Capability GC interval (ms). Drives eviction of stale
421    /// capability index entries.
422    capability_gc_interval_ms: Option<u64>,
423    /// Reject unsigned capability announcements when `true`.
424    /// Defaults to the core's default (`false` in v1).
425    require_signed_capabilities: Option<bool>,
426    /// 1–4 bytes, each 0–255. Leave unset for `SubnetId::GLOBAL`.
427    subnet: Option<Vec<u32>>,
428    /// Optional `{"rules": [{"tag_prefix", "level", "values"}]}` policy.
429    subnet_policy: Option<SubnetPolicyJson>,
430    /// Subnet AUTHORITY trust anchors (review-10 P1-7) — the plane that
431    /// decides which authorities this node will accept protected subnet
432    /// assertions from. Distinct from `subnet` / `subnet_policy` above,
433    /// which are unauthenticated routing state.
434    ///
435    /// `[{"authority_hex", "root_hexes": [..], "maximum_grant_lifetime_secs"}]`.
436    /// An empty or absent list means every protected subnet assertion
437    /// fails closed. Duplicate authorities, empty root sets, duplicate
438    /// roots, and zero lifetimes are refused HERE, before the node
439    /// exists.
440    #[serde(default)]
441    subnet_authorities:
442        Option<Vec<crate::adapter::net::subnet::provision::dto::SubnetAuthorityConfigDto>>,
443    /// This node's own SECURITY attachment point — the local topology
444    /// coordinate credentials are checked against, as `[levels]`
445    /// (0–4 entries, each 0–255). Distinct from `subnet`; omitting it
446    /// preserves the core compatibility fallback, which protected
447    /// deployments should not rely on.
448    #[serde(default)]
449    subnet_attachment: Option<Vec<u8>>,
450    /// Treat an ordinary configured channel as a subnet control-fact
451    /// ARRIVAL path. Confers no authority — facts verify by signature
452    /// regardless of how they arrive.
453    #[serde(default)]
454    subnet_control_channel: Option<String>,
455    /// NAMED subnet exports (review-10 P1-6): the provider-local labels
456    /// `net_subnet_serve_exported` resolves against.
457    ///
458    /// `[{"name", "access": "sameOrg"|"granted",
459    ///    "binding": {"subnet": {"authority_hex", "path": {"levels": [..]}},
460    ///                "topology_epoch"}}]`.
461    ///
462    /// Resolved ONCE here into a checked map held by the node, so the
463    /// name→binding resolution is Rust-owned at the C boundary too —
464    /// which has no wrapper object to hold a map of its own. Empty and
465    /// duplicate labels are refused before the node exists.
466    #[serde(default)]
467    subnet_exports: Option<Vec<crate::adapter::net::subnet::provision::dto::SubnetNamedExportDto>>,
468    /// Hex-encoded 32-byte ed25519 seed — when present, the mesh
469    /// reproduces the same `entity_id` as
470    /// `IdentityFromSeed(sameSeed)`. Leave unset to generate a fresh
471    /// keypair.
472    identity_seed_hex: Option<String>,
473    /// Pin this mesh's publicly-advertised reflex address (an
474    /// `"ip:port"` string). Classification is skipped; the node
475    /// starts in `nat:open` with this address on its capability
476    /// announcements. Silently ignored when the cdylib is built
477    /// without `--features nat-traversal`.
478    #[serde(default)]
479    reflex_override: Option<String>,
480    /// Opt into opportunistic UPnP / NAT-PMP / PCP port mapping
481    /// at startup. Silently ignored when the cdylib is built
482    /// without `--features port-mapping`.
483    #[serde(default)]
484    try_port_mapping: bool,
485    /// Enable the background direct-path upgrade: relay-routed
486    /// sessions are opportunistically re-handshaked over a direct
487    /// path and migrated (Stage 3; optimization, not correctness —
488    /// traffic rides the relay until the swap). Silently ignored
489    /// when the cdylib is built without `--features nat-traversal`.
490    ///
491    /// Tri-state on purpose: absent inherits the core default (on),
492    /// `false` is an explicit kill switch. A plain `bool` here would
493    /// collapse "unset" into "off" and make the flag impossible to
494    /// disable through a JSON surface that omits empty values.
495    #[serde(default)]
496    auto_direct_upgrade: Option<bool>,
497}
498
499/// FFI handle for a [`MeshNode`].
500///
501/// `HandleGuard`-protected: the box stays leaked across `_free`;
502/// ops register via `try_enter` and `_free` quiesces them via
503/// `begin_free`. Without this, an unconditional `Box::from_raw`
504/// would race concurrent `net_mesh_send` (and ~60 other entry
505/// points) into UAF on the dropped Box.
506///
507/// `inner` and `channel_configs` live in `ManuallyDrop` so
508/// `_free` can take them out after the drain. Other Arc clones
509/// held by surviving `MeshStreamHandle._node` keep `MeshNode`
510/// alive until those streams are also freed.
511pub struct MeshNodeHandle {
512    inner: ManuallyDrop<Arc<MeshNode>>,
513    channel_configs: ManuallyDrop<Arc<ChannelConfigRegistry>>,
514    guard: HandleGuard,
515}
516
517/// Create a new mesh node. `config_json` is:
518///
519/// ```json
520/// {
521///   "bind_addr": "127.0.0.1:9000",
522///   "psk_hex":   "42424242...",   // 64 hex chars
523///   "heartbeat_ms": 5000,
524///   "session_timeout_ms": 30000,
525///   "num_shards": 4
526/// }
527/// ```
528///
529/// Installs an empty `ChannelConfigRegistry` at creation time so
530/// `net_mesh_register_channel` can insert without a mutable ref.
531#[unsafe(no_mangle)]
532pub unsafe extern "C" fn net_mesh_new(
533    config_json: *const c_char,
534    out_handle: *mut *mut MeshNodeHandle,
535) -> c_int {
536    if config_json.is_null() || out_handle.is_null() {
537        return NetError::NullPointer.into();
538    }
539    let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
540        return NetError::InvalidUtf8.into();
541    };
542    let cfg: MeshNewConfig = match serde_json::from_str(&s) {
543        Ok(v) => v,
544        Err(_) => return NetError::InvalidJson.into(),
545    };
546    let bind_addr: std::net::SocketAddr = match cfg.bind_addr.parse() {
547        Ok(a) => a,
548        Err(_) => return NET_ERR_MESH_INIT,
549    };
550    let psk_bytes = match hex::decode(&cfg.psk_hex) {
551        Ok(b) => b,
552        Err(_) => return NET_ERR_MESH_INIT,
553    };
554    if psk_bytes.len() != 32 {
555        return NET_ERR_MESH_INIT;
556    }
557    let mut psk = [0u8; 32];
558    psk.copy_from_slice(&psk_bytes);
559
560    let mut node_cfg = MeshNodeConfig::new(bind_addr, psk);
561    // Reject `0` for `heartbeat_ms` and `session_timeout_ms`.
562    // A zero heartbeat interval busy-loops the heartbeat task
563    // (saturating a CPU); a zero session timeout makes every
564    // session expire instantly. The Rust-side configs do their
565    // own validation but the FFI JSON path bypasses that — pin
566    // the guard here so a misconfig fails fast rather than
567    // producing a hung daemon.
568    if let Some(ms) = cfg.heartbeat_ms {
569        if ms == 0 {
570            return NetError::InvalidJson.into();
571        }
572        node_cfg = node_cfg.with_heartbeat_interval(std::time::Duration::from_millis(ms));
573    }
574    if let Some(ms) = cfg.session_timeout_ms {
575        if ms == 0 {
576            return NetError::InvalidJson.into();
577        }
578        node_cfg = node_cfg.with_session_timeout(std::time::Duration::from_millis(ms));
579    }
580    if let Some(n) = cfg.num_shards {
581        node_cfg = node_cfg.with_num_shards(n);
582    }
583    if let Some(ms) = cfg.capability_gc_interval_ms {
584        node_cfg = node_cfg.with_capability_gc_interval(std::time::Duration::from_millis(ms));
585    }
586    if let Some(b) = cfg.require_signed_capabilities {
587        node_cfg = node_cfg.with_require_signed_capabilities(b);
588    }
589    if let Some(levels) = cfg.subnet {
590        let Some(id) = subnet_id_from_json(levels) else {
591            return NET_ERR_MESH_INIT;
592        };
593        node_cfg = node_cfg.with_subnet(id);
594    }
595    if let Some(policy_js) = cfg.subnet_policy {
596        let Some(policy) = subnet_policy_from_json(policy_js) else {
597            return NET_ERR_MESH_INIT;
598        };
599        node_cfg = node_cfg.with_subnet_policy(Arc::new(policy));
600    }
601    // Subnet AUTHORITY plane (review-10 P1-7). Converted and validated
602    // through the SAME frozen DTOs the Rust, Node, and Python
603    // constructors use — that conversion now lives in the core
604    // (`subnet::provision`) precisely so this constructor can reach it,
605    // which is what makes Go and C first-class here rather than
606    // gateway-incapable. Every configuration mistake refuses before the
607    // node exists.
608    {
609        use crate::adapter::net::subnet::provision;
610        let authorities = cfg.subnet_authorities.unwrap_or_default();
611        let mut core_authorities = Vec::with_capacity(authorities.len());
612        for dto in &authorities {
613            let Ok(a) = dto.to_core() else {
614                return NET_ERR_MESH_INIT;
615            };
616            core_authorities.push(a);
617        }
618        if provision::validate_subnet_authorities(&core_authorities).is_err() {
619            return NET_ERR_MESH_INIT;
620        }
621        for authority in core_authorities {
622            node_cfg = node_cfg.with_subnet_authority(authority);
623        }
624        if let Some(levels) = cfg.subnet_attachment {
625            let Ok(path) = (provision::dto::SubnetPathDto { levels }).to_core() else {
626                return NET_ERR_MESH_INIT;
627            };
628            // Direct field write: the core deliberately has no
629            // `with_subnet_attachment` (the `configured_identity`
630            // precedent).
631            node_cfg.subnet_attachment = Some(path);
632        }
633        if let Some(name) = cfg.subnet_control_channel {
634            let Ok(channel) = crate::adapter::net::ChannelName::new(&name) else {
635                return NET_ERR_MESH_INIT;
636            };
637            node_cfg = node_cfg.with_subnet_control_channel(channel);
638        }
639        for dto in cfg.subnet_exports.unwrap_or_default().iter() {
640            let Ok(export) = dto.to_core() else {
641                return NET_ERR_MESH_INIT;
642            };
643            node_cfg = node_cfg.with_subnet_export(export);
644        }
645        // Empty / duplicate labels are refused by `MeshNode::new`, which
646        // freezes the map — one checker, not a second copy here.
647    }
648    #[cfg(feature = "nat-traversal")]
649    if let Some(external_str) = cfg.reflex_override.as_deref() {
650        let Ok(external) = external_str.parse::<std::net::SocketAddr>() else {
651            return NET_ERR_MESH_INIT;
652        };
653        node_cfg = node_cfg.with_reflex_override(external);
654    }
655    // Silently drop the field in builds without nat-traversal so
656    // Go callers compiled against a full-feature cdylib can fall
657    // back to a thin cdylib without a JSON-parse error.
658    #[cfg(not(feature = "nat-traversal"))]
659    let _ = cfg.reflex_override;
660    #[cfg(feature = "port-mapping")]
661    if cfg.try_port_mapping {
662        node_cfg = node_cfg.with_try_port_mapping(true);
663    }
664    // Same drop-on-the-floor pattern as reflex_override above.
665    #[cfg(not(feature = "port-mapping"))]
666    let _ = cfg.try_port_mapping;
667    #[cfg(feature = "nat-traversal")]
668    if let Some(enabled) = cfg.auto_direct_upgrade {
669        node_cfg = node_cfg.with_auto_direct_upgrade(enabled);
670    }
671    // Same drop-on-the-floor pattern as reflex_override above.
672    #[cfg(not(feature = "nat-traversal"))]
673    let _ = cfg.auto_direct_upgrade;
674
675    // Record identity provenance (§D1a of ORG_CAPABILITY_LANGUAGE_SDKS_PLAN):
676    // a caller-supplied seed is a durable, org-bindable identity; a generated
677    // fallback is ephemeral. The org facade reads this through
678    // `MeshNode::has_configured_identity()` to refuse binding on an ephemeral
679    // node. This is the third mesh constructor to need it — the napi and PyO3
680    // ones each silently omitted it and refused a seeded caller until fixed.
681    node_cfg.configured_identity = cfg.identity_seed_hex.is_some();
682
683    let identity = match cfg.identity_seed_hex {
684        Some(seed_hex) => {
685            let bytes = match hex::decode(&seed_hex) {
686                Ok(b) => b,
687                Err(_) => return NET_ERR_MESH_INIT,
688            };
689            if bytes.len() != 32 {
690                return NET_ERR_MESH_INIT;
691            }
692            let mut arr = [0u8; 32];
693            arr.copy_from_slice(&bytes);
694            EntityKeypair::from_bytes(arr)
695        }
696        None => EntityKeypair::generate(),
697    };
698    let result = block_on(async move { MeshNode::new(identity, node_cfg).await });
699    match result {
700        Ok(mut node) => {
701            let channel_configs = Arc::new(ChannelConfigRegistry::new());
702            node.set_channel_configs(channel_configs.clone());
703            // Install a fresh TokenCache — channel auth needs one to
704            // supply the RevocationRegistry and clock-skew tolerance,
705            // and `require_token` channels reject outright without it.
706            // Subscriber-presented tokens do NOT land here; they are
707            // verified inline against the channel's `token_roots` and
708            // retained as chains. Matches the PyO3 / NAPI behaviour.
709            node.set_token_cache(Arc::new(TokenCache::new()));
710            let handle = Box::new(MeshNodeHandle {
711                inner: ManuallyDrop::new(Arc::new(node)),
712                channel_configs: ManuallyDrop::new(channel_configs),
713                guard: HandleGuard::new(),
714            });
715            unsafe {
716                *out_handle = Box::into_raw(handle);
717            }
718            0
719        }
720        Err(_) => NET_ERR_MESH_INIT,
721    }
722}
723
724#[unsafe(no_mangle)]
725pub unsafe extern "C" fn net_mesh_free(handle: *mut MeshNodeHandle) {
726    if handle.is_null() {
727        return;
728    }
729    // Quiesce in-flight ops before dropping the inner. Box stays
730    // leaked. Other Arc clones held by surviving
731    // MeshStreamHandle._node keep MeshNode alive until their own
732    // _free runs.
733    let h: &MeshNodeHandle = unsafe { &*handle };
734    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
735        // SAFETY: drained; sole writable reference.
736        unsafe {
737            let mh = &mut *handle;
738            let inner = ManuallyDrop::take(&mut mh.inner);
739            let configs = ManuallyDrop::take(&mut mh.channel_configs);
740            drop(inner);
741            drop(configs);
742        }
743    } else {
744        tracing::warn!(
745            "net_mesh_free: in-flight ops did not drain within deadline; \
746             leaking inner to avoid use-after-free"
747        );
748    }
749}
750
751/// Crate-internal accessor: return an `Arc<MeshNode>` clone
752/// from a borrowed handle without crossing the FFI boundary.
753/// Used by sibling FFI modules (`ffi::aggregator`) that need
754/// the inner Arc without round-tripping through the extern
755/// `net_mesh_arc_clone` + `net_mesh_arc_free` pair. The only
756/// consumer (`ffi::aggregator`) is itself cortex-feature-only,
757/// so the gate keeps the symbol out of cortex-off builds and
758/// avoids a dead-code warning.
759///
760/// Gated on the handle's [`HandleGuard`]: the `try_enter` op is held
761/// across the `Arc::clone` so a concurrent `net_mesh_free` cannot take
762/// the inner out of `ManuallyDrop` mid-clone. Returns `None` if `_free`
763/// has begun — callers must surface a null/error result. Once the clone
764/// lands the bumped refcount keeps the node alive independently.
765// Available to the aggregator FFI (`cortex`) and the transport FFI
766// (`dataforts`), both of which clone the node Arc to drive an op under
767// the handle guard.
768#[cfg(any(feature = "cortex", feature = "dataforts"))]
769pub(super) fn mesh_node_arc(h: &MeshNodeHandle) -> Option<Arc<MeshNode>> {
770    let _op = h.guard.try_enter()?;
771    Some(Arc::clone(&h.inner))
772}
773
774/// Clone the `Arc<MeshNode>` backing this handle and return a
775/// `*mut Arc<MeshNode>`. Used by the compute-FFI crate so the
776/// Go binding's `DaemonRuntime` can share the live mesh node
777/// without opening a second socket.
778///
779/// Caller takes ownership of the returned pointer and MUST free it
780/// with [`net_mesh_arc_free`]. Returns NULL if `handle` is NULL.
781#[unsafe(no_mangle)]
782pub unsafe extern "C" fn net_mesh_arc_clone(handle: *mut MeshNodeHandle) -> *mut Arc<MeshNode> {
783    if handle.is_null() {
784        return std::ptr::null_mut();
785    }
786    let h = unsafe { &*handle };
787    // Returns NULL on shutting-down — same shape as absent-handle.
788    let _op = match h.guard.try_enter() {
789        Some(op) => op,
790        None => return std::ptr::null_mut(),
791    };
792    let cloned: Arc<MeshNode> = Arc::clone(&h.inner);
793    Box::into_raw(Box::new(cloned))
794}
795
796/// Clone the shared `Arc<ChannelConfigRegistry>` backing this
797/// handle. Used by compute-FFI so migration-triggered channel
798/// rebind replays hit the same registry the mesh publishes to.
799///
800/// Caller takes ownership and MUST free with
801/// [`net_mesh_channel_configs_arc_free`].
802#[unsafe(no_mangle)]
803pub unsafe extern "C" fn net_mesh_channel_configs_arc_clone(
804    handle: *mut MeshNodeHandle,
805) -> *mut Arc<ChannelConfigRegistry> {
806    if handle.is_null() {
807        return std::ptr::null_mut();
808    }
809    let h = unsafe { &*handle };
810    // Returns NULL on shutting-down — same shape as absent-handle.
811    let _op = match h.guard.try_enter() {
812        Some(op) => op,
813        None => return std::ptr::null_mut(),
814    };
815    let cloned: Arc<ChannelConfigRegistry> = Arc::clone(&h.channel_configs);
816    Box::into_raw(Box::new(cloned))
817}
818
819/// Free an `Arc<MeshNode>` handle produced by
820/// [`net_mesh_arc_clone`]. Idempotent on NULL.
821#[unsafe(no_mangle)]
822pub unsafe extern "C" fn net_mesh_arc_free(p: *mut Arc<MeshNode>) {
823    if p.is_null() {
824        return;
825    }
826    unsafe {
827        drop(Box::from_raw(p));
828    }
829}
830
831/// Free an `Arc<ChannelConfigRegistry>` handle produced by
832/// [`net_mesh_channel_configs_arc_clone`]. Idempotent on NULL.
833#[unsafe(no_mangle)]
834pub unsafe extern "C" fn net_mesh_channel_configs_arc_free(p: *mut Arc<ChannelConfigRegistry>) {
835    if p.is_null() {
836        return;
837    }
838    unsafe {
839        drop(Box::from_raw(p));
840    }
841}
842
843/// Write the hex-encoded 32-byte Noise static public key of this
844/// node to `*out`. Caller frees via `net_free_string`.
845#[unsafe(no_mangle)]
846pub unsafe extern "C" fn net_mesh_public_key_hex(
847    handle: *mut MeshNodeHandle,
848    out_ptr: *mut *mut c_char,
849    out_len: *mut usize,
850) -> c_int {
851    if handle.is_null() || out_ptr.is_null() || out_len.is_null() {
852        return NetError::NullPointer.into();
853    }
854    let h = unsafe { &*handle };
855    let _op = match h.guard.try_enter() {
856        Some(op) => op,
857        None => return NetError::ShuttingDown.into(),
858    };
859    let s = hex::encode(h.inner.public_key());
860    write_string_out(s, out_ptr, out_len)
861}
862
863#[unsafe(no_mangle)]
864pub unsafe extern "C" fn net_mesh_node_id(handle: *mut MeshNodeHandle) -> u64 {
865    if handle.is_null() {
866        return 0;
867    }
868    let h = unsafe { &*handle };
869    // Returns 0 on shutting-down — same shape as absent-handle.
870    let _op = match h.guard.try_enter() {
871        Some(op) => op,
872        None => return 0,
873    };
874    h.inner.node_id()
875}
876
877/// Writes the 32-byte ed25519 entity id of this mesh into `out[32]`.
878/// Matches `Identity::from_seed(seed).entity_id` when the mesh was
879/// constructed with `identity_seed_hex = hex::encode(seed)`.
880#[unsafe(no_mangle)]
881pub unsafe extern "C" fn net_mesh_entity_id(handle: *mut MeshNodeHandle, out: *mut u8) -> c_int {
882    if handle.is_null() || out.is_null() {
883        return NetError::NullPointer.into();
884    }
885    let h = unsafe { &*handle };
886    let _op = match h.guard.try_enter() {
887        Some(op) => op,
888        None => return NetError::ShuttingDown.into(),
889    };
890    let bytes = h.inner.entity_id().as_bytes();
891    unsafe {
892        std::ptr::copy_nonoverlapping(bytes.as_ptr(), out, 32);
893    }
894    0
895}
896/// Parse a NUL-terminated 64-char-hex peer public key into its
897/// 32-byte form. Shared by every `net_mesh_connect*` entry point so
898/// the validation rules and error codes can't drift apart between
899/// wrappers (cubic P2). Error codes match what the wrappers
900/// historically returned inline: `InvalidUtf8` for a non-UTF-8 C
901/// string, `NET_ERR_MESH_HANDSHAKE` for bad hex or a wrong-length
902/// key.
903///
904/// # Safety
905///
906/// `peer_pubkey_hex` must be a valid, NUL-terminated C string
907/// pointer (callers null-check before invoking).
908unsafe fn parse_peer_pubkey_hex(peer_pubkey_hex: *const c_char) -> Result<[u8; 32], c_int> {
909    let Some(pk_s) = (unsafe { c_str_to_string(peer_pubkey_hex) }) else {
910        return Err(NetError::InvalidUtf8.into());
911    };
912    let pk_bytes = match hex::decode(pk_s) {
913        Ok(b) => b,
914        Err(_) => return Err(NET_ERR_MESH_HANDSHAKE),
915    };
916    if pk_bytes.len() != 32 {
917        return Err(NET_ERR_MESH_HANDSHAKE);
918    }
919    let mut pk = [0u8; 32];
920    pk.copy_from_slice(&pk_bytes);
921    Ok(pk)
922}
923
924/// Connect (initiator). Blocks until the handshake completes.
925#[unsafe(no_mangle)]
926pub unsafe extern "C" fn net_mesh_connect(
927    handle: *mut MeshNodeHandle,
928    peer_addr: *const c_char,
929    peer_pubkey_hex: *const c_char,
930    peer_node_id: u64,
931) -> c_int {
932    if handle.is_null() || peer_addr.is_null() || peer_pubkey_hex.is_null() {
933        return NetError::NullPointer.into();
934    }
935    let h = unsafe { &*handle };
936    let _op = match h.guard.try_enter() {
937        Some(op) => op,
938        None => return NetError::ShuttingDown.into(),
939    };
940    let Some(addr_s) = (unsafe { c_str_to_string(peer_addr) }) else {
941        return NetError::InvalidUtf8.into();
942    };
943    let addr: std::net::SocketAddr = match addr_s.parse() {
944        Ok(a) => a,
945        Err(_) => return NET_ERR_MESH_HANDSHAKE,
946    };
947    let pk = match unsafe { parse_peer_pubkey_hex(peer_pubkey_hex) } {
948        Ok(pk) => pk,
949        Err(code) => return code,
950    };
951
952    let node = h.inner.clone();
953    match block_on(async move { node.connect(addr, &pk, peer_node_id).await }) {
954        Ok(_) => 0,
955        Err(e) => adapter_err_to_code(&e),
956    }
957}
958
959/// Accept an incoming connection (responder). Writes the peer's wire
960/// address to `*out_addr` (caller frees via `net_free_string`).
961#[unsafe(no_mangle)]
962pub unsafe extern "C" fn net_mesh_accept(
963    handle: *mut MeshNodeHandle,
964    peer_node_id: u64,
965    out_addr: *mut *mut c_char,
966    out_len: *mut usize,
967) -> c_int {
968    if handle.is_null() || out_addr.is_null() || out_len.is_null() {
969        return NetError::NullPointer.into();
970    }
971    let h = unsafe { &*handle };
972    let _op = match h.guard.try_enter() {
973        Some(op) => op,
974        None => return NetError::ShuttingDown.into(),
975    };
976    let node = h.inner.clone();
977    match block_on(async move { node.accept(peer_node_id).await }) {
978        Ok((addr, _)) => write_string_out(addr.to_string(), out_addr, out_len),
979        Err(e) => adapter_err_to_code(&e),
980    }
981}
982
983#[unsafe(no_mangle)]
984pub unsafe extern "C" fn net_mesh_start(handle: *mut MeshNodeHandle) -> c_int {
985    if handle.is_null() {
986        return NetError::NullPointer.into();
987    }
988    let h = unsafe { &*handle };
989    let _op = match h.guard.try_enter() {
990        Some(op) => op,
991        None => return NetError::ShuttingDown.into(),
992    };
993    let node = h.inner.clone();
994    // `start` spawns internal tasks via tokio::spawn; run under the
995    // shared runtime. `start_arc` also enables the periodic capability
996    // re-announce (keeps the node discoverable past one TTL).
997    block_on(async move { node.start_arc() });
998    0
999}
1000
1001/// Shut down the node. Must be called before `net_mesh_free` to
1002/// release network resources. Idempotent.
1003///
1004/// Runs unconditionally — `MeshNode::shutdown` takes `&self` and
1005/// the underlying primitives (shutdown flag, notify, deactivate)
1006/// are safe to call while other handles still hold the `Arc`. A
1007/// prior version silently returned 0 whenever `Arc::strong_count`
1008/// exceeded 1, which meant a caller that held a stream handle
1009/// would see "shutdown successful" without any tasks actually
1010/// stopping — the node kept running until every stream was
1011/// dropped. Callers now always get the real shutdown outcome.
1012#[unsafe(no_mangle)]
1013pub unsafe extern "C" fn net_mesh_shutdown(handle: *mut MeshNodeHandle) -> c_int {
1014    if handle.is_null() {
1015        return NetError::NullPointer.into();
1016    }
1017    let h = unsafe { &*handle };
1018    let _op = match h.guard.try_enter() {
1019        Some(op) => op,
1020        None => return NetError::ShuttingDown.into(),
1021    };
1022    match block_on(async { h.inner.shutdown().await }) {
1023        Ok(()) => 0,
1024        Err(e) => adapter_err_to_code(&e),
1025    }
1026}
1027
1028// =========================================================================
1029// NAT traversal
1030// =========================================================================
1031//
1032// Framing (plan §5, load-bearing): every user-visible docstring
1033// positions NAT traversal as **optimization, not correctness**.
1034// Nodes behind NAT can always reach each other through the
1035// routed-handshake path. A `nat_type` of `"symmetric"` or any
1036// `NET_ERR_TRAVERSAL_*` code is not a connectivity failure —
1037// traffic keeps riding the relay. Each function returns early
1038// with `NetError::Unsupported` (= -1 NetError variant) when the
1039// crate is built without `nat-traversal`, so cgo call sites that
1040// unconditionally reference these symbols still link.
1041
1042/// Write this mesh's NAT classification into `out_str` as one of
1043/// `"open" | "cone" | "symmetric" | "unknown"`. Stable vocabulary
1044/// — matches the NAPI / PyO3 binding strings. Caller frees via
1045/// `net_free_string`.
1046///
1047/// Returns `0` on success or a NetError code on failure. Only
1048/// present when the crate is built with `--features nat-traversal`.
1049#[cfg(feature = "nat-traversal")]
1050#[unsafe(no_mangle)]
1051pub unsafe extern "C" fn net_mesh_nat_type(
1052    handle: *mut MeshNodeHandle,
1053    out_str: *mut *mut c_char,
1054    out_len: *mut usize,
1055) -> c_int {
1056    if handle.is_null() || out_str.is_null() || out_len.is_null() {
1057        return NetError::NullPointer.into();
1058    }
1059    let h = unsafe { &*handle };
1060    let _op = match h.guard.try_enter() {
1061        Some(op) => op,
1062        None => return NetError::ShuttingDown.into(),
1063    };
1064    write_string_out(
1065        nat_class_to_str(h.inner.nat_class()).to_string(),
1066        out_str,
1067        out_len,
1068    )
1069}
1070
1071/// Write this mesh's last-observed reflex `ip:port` into
1072/// `out_str`. When no reflex has been observed yet (pre-
1073/// classification, or only one peer connected), writes an empty
1074/// string and still returns `0`.
1075#[cfg(feature = "nat-traversal")]
1076#[unsafe(no_mangle)]
1077pub unsafe extern "C" fn net_mesh_reflex_addr(
1078    handle: *mut MeshNodeHandle,
1079    out_str: *mut *mut c_char,
1080    out_len: *mut usize,
1081) -> c_int {
1082    if handle.is_null() || out_str.is_null() || out_len.is_null() {
1083        return NetError::NullPointer.into();
1084    }
1085    let h = unsafe { &*handle };
1086    let _op = match h.guard.try_enter() {
1087        Some(op) => op,
1088        None => return NetError::ShuttingDown.into(),
1089    };
1090    let s = h
1091        .inner
1092        .reflex_addr()
1093        .map(|a| a.to_string())
1094        .unwrap_or_default();
1095    write_string_out(s, out_str, out_len)
1096}
1097
1098/// Write `peer_node_id`'s advertised NAT classification (read
1099/// from its `nat:*` capability tag) into `out_str`. Returns
1100/// `"unknown"` when we have no announcement from that peer.
1101#[cfg(feature = "nat-traversal")]
1102#[unsafe(no_mangle)]
1103pub unsafe extern "C" fn net_mesh_peer_nat_type(
1104    handle: *mut MeshNodeHandle,
1105    peer_node_id: u64,
1106    out_str: *mut *mut c_char,
1107    out_len: *mut usize,
1108) -> c_int {
1109    if handle.is_null() || out_str.is_null() || out_len.is_null() {
1110        return NetError::NullPointer.into();
1111    }
1112    let h = unsafe { &*handle };
1113    let _op = match h.guard.try_enter() {
1114        Some(op) => op,
1115        None => return NetError::ShuttingDown.into(),
1116    };
1117    write_string_out(
1118        nat_class_to_str(h.inner.peer_nat_class(peer_node_id)).to_string(),
1119        out_str,
1120        out_len,
1121    )
1122}
1123
1124/// Send one reflex probe to `peer_node_id` and write the public
1125/// `ip:port` the peer observed into `out_str`. Blocks on the
1126/// shared runtime until the probe completes or times out.
1127///
1128/// Returns `0` on success or a `NET_ERR_TRAVERSAL_*` code on
1129/// failure. `NET_ERR_TRAVERSAL_REFLEX_TIMEOUT` means the probe
1130/// didn't complete in time; `NET_ERR_TRAVERSAL_PEER_NOT_REACHABLE`
1131/// means we have no session with `peer_node_id`.
1132#[cfg(feature = "nat-traversal")]
1133#[unsafe(no_mangle)]
1134pub unsafe extern "C" fn net_mesh_probe_reflex(
1135    handle: *mut MeshNodeHandle,
1136    peer_node_id: u64,
1137    out_str: *mut *mut c_char,
1138    out_len: *mut usize,
1139) -> c_int {
1140    if handle.is_null() || out_str.is_null() || out_len.is_null() {
1141        return NetError::NullPointer.into();
1142    }
1143    let h = unsafe { &*handle };
1144    let _op = match h.guard.try_enter() {
1145        Some(op) => op,
1146        None => return NetError::ShuttingDown.into(),
1147    };
1148    let node = h.inner.clone();
1149    match block_on(async move { node.probe_reflex(peer_node_id).await }) {
1150        Ok(addr) => write_string_out(addr.to_string(), out_str, out_len),
1151        Err(e) => traversal_err_to_code(&e),
1152    }
1153}
1154
1155/// Explicitly re-run the NAT classification sweep. No-op when
1156/// fewer than 2 peers are connected. Never returns an error;
1157/// callers that want the result should read `nat_type` +
1158/// `reflex_addr` afterward.
1159#[cfg(feature = "nat-traversal")]
1160#[unsafe(no_mangle)]
1161pub unsafe extern "C" fn net_mesh_reclassify_nat(handle: *mut MeshNodeHandle) -> c_int {
1162    if handle.is_null() {
1163        return NetError::NullPointer.into();
1164    }
1165    let h = unsafe { &*handle };
1166    let _op = match h.guard.try_enter() {
1167        Some(op) => op,
1168        None => return NetError::ShuttingDown.into(),
1169    };
1170    let node = h.inner.clone();
1171    block_on(async move { node.reclassify_nat().await });
1172    0
1173}
1174
1175/// Fill `out_punches_attempted`, `out_punches_succeeded`,
1176/// `out_relay_fallbacks` with the current cumulative counters.
1177/// Each pointer may be null to skip that field. Monotonic —
1178/// counters never decrease or reset.
1179#[cfg(feature = "nat-traversal")]
1180#[unsafe(no_mangle)]
1181pub unsafe extern "C" fn net_mesh_traversal_stats(
1182    handle: *mut MeshNodeHandle,
1183    out_punches_attempted: *mut u64,
1184    out_punches_succeeded: *mut u64,
1185    out_relay_fallbacks: *mut u64,
1186) -> c_int {
1187    if handle.is_null() {
1188        return NetError::NullPointer.into();
1189    }
1190    let h = unsafe { &*handle };
1191    let _op = match h.guard.try_enter() {
1192        Some(op) => op,
1193        None => return NetError::ShuttingDown.into(),
1194    };
1195    let snap = h.inner.traversal_stats();
1196    unsafe {
1197        if !out_punches_attempted.is_null() {
1198            *out_punches_attempted = snap.punches_attempted;
1199        }
1200        if !out_punches_succeeded.is_null() {
1201            *out_punches_succeeded = snap.punches_succeeded;
1202        }
1203        if !out_relay_fallbacks.is_null() {
1204            *out_relay_fallbacks = snap.relay_fallbacks;
1205        }
1206    }
1207    0
1208}
1209
1210/// Establish a session to `peer_node_id` via rendezvous through
1211/// `coordinator`, picking between direct-handshake and a
1212/// coordinated punch per the pair-type matrix. Always resolves
1213/// (on punch-failed, falls back to routed). Inspect the stats
1214/// counters afterward to distinguish outcomes.
1215///
1216/// `peer_pubkey_hex` is the peer's 32-byte Noise static public
1217/// key as a 64-char hex string.
1218///
1219/// Returns `0` on success or a `NET_ERR_TRAVERSAL_*` /
1220/// `NET_ERR_MESH_HANDSHAKE` code on failure.
1221#[cfg(feature = "nat-traversal")]
1222#[unsafe(no_mangle)]
1223pub unsafe extern "C" fn net_mesh_connect_direct(
1224    handle: *mut MeshNodeHandle,
1225    peer_node_id: u64,
1226    peer_pubkey_hex: *const c_char,
1227    coordinator: u64,
1228) -> c_int {
1229    if handle.is_null() || peer_pubkey_hex.is_null() {
1230        return NetError::NullPointer.into();
1231    }
1232    let h = unsafe { &*handle };
1233    let _op = match h.guard.try_enter() {
1234        Some(op) => op,
1235        None => return NetError::ShuttingDown.into(),
1236    };
1237    let pk = match unsafe { parse_peer_pubkey_hex(peer_pubkey_hex) } {
1238        Ok(pk) => pk,
1239        Err(code) => return code,
1240    };
1241
1242    let node = h.inner.clone();
1243    match block_on(async move { node.connect_direct(peer_node_id, &pk, coordinator).await }) {
1244        Ok(_) => 0,
1245        Err(e) => traversal_err_to_code(&e),
1246    }
1247}
1248
1249/// Like `net_mesh_connect_direct`, but auto-selects the rendezvous
1250/// coordinator (routing next-hop → `relay-capable` mutual peer →
1251/// any mutual peer). Punch-needing pairs with no candidate fail
1252/// with `NET_ERR_TRAVERSAL_RENDEZVOUS_NO_RELAY` — the caller stays
1253/// on the routed path; connectivity is never at risk.
1254///
1255/// `peer_pubkey_hex` is the peer's 32-byte Noise static public
1256/// key as a 64-char hex string.
1257#[cfg(feature = "nat-traversal")]
1258#[unsafe(no_mangle)]
1259pub unsafe extern "C" fn net_mesh_connect_direct_auto(
1260    handle: *mut MeshNodeHandle,
1261    peer_node_id: u64,
1262    peer_pubkey_hex: *const c_char,
1263) -> c_int {
1264    if handle.is_null() || peer_pubkey_hex.is_null() {
1265        return NetError::NullPointer.into();
1266    }
1267    let h = unsafe { &*handle };
1268    let _op = match h.guard.try_enter() {
1269        Some(op) => op,
1270        None => return NetError::ShuttingDown.into(),
1271    };
1272    let pk = match unsafe { parse_peer_pubkey_hex(peer_pubkey_hex) } {
1273        Ok(pk) => pk,
1274        Err(code) => return code,
1275    };
1276
1277    let node = h.inner.clone();
1278    match block_on(async move { node.connect_direct_auto(peer_node_id, &pk).await }) {
1279        Ok(_) => 0,
1280        Err(e) => traversal_err_to_code(&e),
1281    }
1282}
1283
1284/// Full traversal-stats snapshot for `net_mesh_traversal_stats_v2`.
1285/// `#[repr(C)]` — field order, widths, and the 64-byte address
1286/// buffer are ABI; matched by `net_traversal_stats_v2_t` in
1287/// `include/net.go.h`. Extend only by appending a new versioned
1288/// struct + call, never by mutating this one.
1289#[repr(C)]
1290pub struct NetTraversalStatsV2 {
1291    /// Punches whose `PunchRequest` was successfully mediated.
1292    pub punches_attempted: u64,
1293    /// Mediated punches that produced a direct session.
1294    pub punches_succeeded: u64,
1295    /// Derived: `punches_attempted - punches_succeeded` (saturating).
1296    pub punches_failed: u64,
1297    /// `connect_direct` calls that resolved on the routed path.
1298    pub relay_fallbacks: u64,
1299    /// Punch flows that gave up on a deadline (cause counter).
1300    pub punch_timeouts: u64,
1301    /// Punch flows refused by a typed `PunchReject` (cause counter).
1302    pub punch_rejections: u64,
1303    /// Punch-needing pairs skipped with no coordinator candidate.
1304    pub rendezvous_no_relay: u64,
1305    /// Background direct-path upgrades started (Stage 3).
1306    pub upgrades_attempted: u64,
1307    /// Upgrades that replaced a relay session with a direct one.
1308    pub upgrades_succeeded: u64,
1309    /// Upgrades deferred by the C3 busy gate (retried; not failures).
1310    pub upgrades_deferred_busy: u64,
1311    /// Successful renewal ticks since the current mapping installed.
1312    pub port_mapping_renewals: u64,
1313    /// 1 when a port mapping is currently installed, else 0.
1314    pub port_mapping_active: u8,
1315    /// NUL-terminated `"ip:port"` of the mapped external address;
1316    /// empty string when no mapping is active. 64 bytes covers the
1317    /// longest textual form (`[v6]:65535` ≤ 54 chars).
1318    pub port_mapping_external: [c_char; 64],
1319}
1320
1321/// Copy a core snapshot into the C-ABI v2 struct. Factored out of
1322/// the extern fn so the field mapping (and the external-address
1323/// string encoding) is unit-testable without a live node.
1324#[cfg(feature = "nat-traversal")]
1325fn fill_traversal_stats_v2(
1326    snap: &crate::adapter::net::traversal::TraversalStatsSnapshot,
1327    out: &mut NetTraversalStatsV2,
1328) {
1329    out.punches_attempted = snap.punches_attempted;
1330    out.punches_succeeded = snap.punches_succeeded;
1331    out.punches_failed = snap.punches_failed;
1332    out.relay_fallbacks = snap.relay_fallbacks;
1333    out.punch_timeouts = snap.punch_timeouts;
1334    out.punch_rejections = snap.punch_rejections;
1335    out.rendezvous_no_relay = snap.rendezvous_no_relay;
1336    out.upgrades_attempted = snap.upgrades_attempted;
1337    out.upgrades_succeeded = snap.upgrades_succeeded;
1338    out.upgrades_deferred_busy = snap.upgrades_deferred_busy;
1339    out.port_mapping_renewals = snap.port_mapping_renewals;
1340    out.port_mapping_active = u8::from(snap.port_mapping_active);
1341    out.port_mapping_external = [0; 64];
1342    if let Some(addr) = snap.port_mapping_external {
1343        let s = addr.to_string();
1344        // Truncation guard: leave the final byte as NUL. `[v6]:port`
1345        // tops out ≤ 54 chars, so this never actually truncates —
1346        // the guard exists so a future address form degrades to a
1347        // clipped string rather than an unterminated buffer.
1348        let n = s.len().min(63);
1349        for (dst, src) in out.port_mapping_external[..n].iter_mut().zip(s.as_bytes()) {
1350            *dst = *src as c_char;
1351        }
1352    }
1353}
1354
1355/// Fill `out` with the complete traversal-stats snapshot — the
1356/// stage-5 v2 surface. The v1 3-out-param
1357/// `net_mesh_traversal_stats` stays ABI-stable for compiled
1358/// consumers; new callers should prefer this one.
1359///
1360/// Base counters are monotonic; two fields are exempt from delta
1361/// math: `punches_failed` is derived at snapshot time
1362/// (`attempted - succeeded`) and can decrease when an in-flight
1363/// punch lands, and `port_mapping_renewals` resets on each fresh
1364/// mapping install. Returns `0` on success.
1365#[cfg(feature = "nat-traversal")]
1366#[unsafe(no_mangle)]
1367pub unsafe extern "C" fn net_mesh_traversal_stats_v2(
1368    handle: *mut MeshNodeHandle,
1369    out: *mut NetTraversalStatsV2,
1370) -> c_int {
1371    if handle.is_null() || out.is_null() {
1372        return NetError::NullPointer.into();
1373    }
1374    let h = unsafe { &*handle };
1375    let _op = match h.guard.try_enter() {
1376        Some(op) => op,
1377        None => return NetError::ShuttingDown.into(),
1378    };
1379    let snap = h.inner.traversal_stats();
1380    fill_traversal_stats_v2(&snap, unsafe { &mut *out });
1381    0
1382}
1383
1384/// Install a runtime reflex override. `external` is a
1385/// UTF-8 / null-terminated `"ip:port"` string. Forces `nat_type`
1386/// to `"open"` and `reflex_addr` to `external` immediately;
1387/// short-circuits any further classifier sweeps.
1388///
1389/// Returns `0` on success or `NET_ERR_MESH_INIT` on a malformed
1390/// address.
1391#[cfg(feature = "nat-traversal")]
1392#[unsafe(no_mangle)]
1393pub unsafe extern "C" fn net_mesh_set_reflex_override(
1394    handle: *mut MeshNodeHandle,
1395    external: *const c_char,
1396) -> c_int {
1397    if handle.is_null() || external.is_null() {
1398        return NetError::NullPointer.into();
1399    }
1400    let h = unsafe { &*handle };
1401    let _op = match h.guard.try_enter() {
1402        Some(op) => op,
1403        None => return NetError::ShuttingDown.into(),
1404    };
1405    let Some(s) = (unsafe { c_str_to_string(external) }) else {
1406        return NetError::InvalidUtf8.into();
1407    };
1408    let Ok(addr) = s.parse::<std::net::SocketAddr>() else {
1409        return NET_ERR_MESH_INIT;
1410    };
1411    h.inner.set_reflex_override(addr);
1412    0
1413}
1414
1415/// Drop a previously-installed reflex override. The classifier
1416/// resumes on its normal cadence; `reflex_addr` clears to empty
1417/// immediately so a between-sweep read doesn't return a stale
1418/// override.
1419///
1420/// No-op when no override is active. Always returns `0` on a
1421/// live handle.
1422#[cfg(feature = "nat-traversal")]
1423#[unsafe(no_mangle)]
1424pub unsafe extern "C" fn net_mesh_clear_reflex_override(handle: *mut MeshNodeHandle) -> c_int {
1425    if handle.is_null() {
1426        return NetError::NullPointer.into();
1427    }
1428    let h = unsafe { &*handle };
1429    let _op = match h.guard.try_enter() {
1430        Some(op) => op,
1431        None => return NetError::ShuttingDown.into(),
1432    };
1433    h.inner.clear_reflex_override();
1434    0
1435}
1436
1437// =========================================================================
1438// NAT-traversal fallback stubs — built when the core is
1439// compiled *without* `--features nat-traversal`.
1440//
1441// Bug L (cubic, P1): the Go / NAPI / PyO3 bindings unconditionally
1442// link against these symbols, so a cdylib without the feature
1443// used to fail at dlopen / load time with missing-symbol
1444// errors. The doc comment on each binding promised
1445// `ErrTraversalUnsupported` as the runtime surface for a no-
1446// feature build, but there were no stubs to back that promise.
1447//
1448// These stubs make the promise real: the symbol resolves, the
1449// call returns `NET_ERR_TRAVERSAL_UNSUPPORTED`, and the Go
1450// error-mapping layer translates that to
1451// `ErrTraversalUnsupported`. No heap allocation — the `_out_*`
1452// pointers are left untouched (the Go side treats them as
1453// invalid on a nonzero return).
1454//
1455// Every signature mirrors the `#[cfg(feature = "nat-traversal")]`
1456// definition above. Ordering matches the feature-on block so
1457// diff review can line up the pair at a glance.
1458
1459#[cfg(not(feature = "nat-traversal"))]
1460#[unsafe(no_mangle)]
1461pub unsafe extern "C" fn net_mesh_nat_type(
1462    _handle: *mut MeshNodeHandle,
1463    _out_str: *mut *mut c_char,
1464    _out_len: *mut usize,
1465) -> c_int {
1466    NET_ERR_TRAVERSAL_UNSUPPORTED
1467}
1468
1469#[cfg(not(feature = "nat-traversal"))]
1470#[unsafe(no_mangle)]
1471pub unsafe extern "C" fn net_mesh_reflex_addr(
1472    _handle: *mut MeshNodeHandle,
1473    _out_str: *mut *mut c_char,
1474    _out_len: *mut usize,
1475) -> c_int {
1476    NET_ERR_TRAVERSAL_UNSUPPORTED
1477}
1478
1479#[cfg(not(feature = "nat-traversal"))]
1480#[unsafe(no_mangle)]
1481pub unsafe extern "C" fn net_mesh_peer_nat_type(
1482    _handle: *mut MeshNodeHandle,
1483    _peer_node_id: u64,
1484    _out_str: *mut *mut c_char,
1485    _out_len: *mut usize,
1486) -> c_int {
1487    NET_ERR_TRAVERSAL_UNSUPPORTED
1488}
1489
1490#[cfg(not(feature = "nat-traversal"))]
1491#[unsafe(no_mangle)]
1492pub unsafe extern "C" fn net_mesh_probe_reflex(
1493    _handle: *mut MeshNodeHandle,
1494    _peer_node_id: u64,
1495    _out_str: *mut *mut c_char,
1496    _out_len: *mut usize,
1497) -> c_int {
1498    NET_ERR_TRAVERSAL_UNSUPPORTED
1499}
1500
1501#[cfg(not(feature = "nat-traversal"))]
1502#[unsafe(no_mangle)]
1503pub unsafe extern "C" fn net_mesh_reclassify_nat(_handle: *mut MeshNodeHandle) -> c_int {
1504    NET_ERR_TRAVERSAL_UNSUPPORTED
1505}
1506
1507#[cfg(not(feature = "nat-traversal"))]
1508#[unsafe(no_mangle)]
1509pub unsafe extern "C" fn net_mesh_traversal_stats(
1510    _handle: *mut MeshNodeHandle,
1511    _out_punches_attempted: *mut u64,
1512    _out_punches_succeeded: *mut u64,
1513    _out_relay_fallbacks: *mut u64,
1514) -> c_int {
1515    NET_ERR_TRAVERSAL_UNSUPPORTED
1516}
1517
1518#[cfg(not(feature = "nat-traversal"))]
1519#[unsafe(no_mangle)]
1520pub unsafe extern "C" fn net_mesh_connect_direct(
1521    _handle: *mut MeshNodeHandle,
1522    _peer_node_id: u64,
1523    _peer_pubkey_hex: *const c_char,
1524    _coordinator: u64,
1525) -> c_int {
1526    NET_ERR_TRAVERSAL_UNSUPPORTED
1527}
1528
1529#[cfg(not(feature = "nat-traversal"))]
1530#[unsafe(no_mangle)]
1531pub unsafe extern "C" fn net_mesh_connect_direct_auto(
1532    _handle: *mut MeshNodeHandle,
1533    _peer_node_id: u64,
1534    _peer_pubkey_hex: *const c_char,
1535) -> c_int {
1536    NET_ERR_TRAVERSAL_UNSUPPORTED
1537}
1538
1539#[cfg(not(feature = "nat-traversal"))]
1540#[unsafe(no_mangle)]
1541pub unsafe extern "C" fn net_mesh_traversal_stats_v2(
1542    _handle: *mut MeshNodeHandle,
1543    _out: *mut NetTraversalStatsV2,
1544) -> c_int {
1545    NET_ERR_TRAVERSAL_UNSUPPORTED
1546}
1547
1548#[cfg(not(feature = "nat-traversal"))]
1549#[unsafe(no_mangle)]
1550pub unsafe extern "C" fn net_mesh_set_reflex_override(
1551    _handle: *mut MeshNodeHandle,
1552    _external: *const c_char,
1553) -> c_int {
1554    NET_ERR_TRAVERSAL_UNSUPPORTED
1555}
1556
1557#[cfg(not(feature = "nat-traversal"))]
1558#[unsafe(no_mangle)]
1559pub unsafe extern "C" fn net_mesh_clear_reflex_override(_handle: *mut MeshNodeHandle) -> c_int {
1560    NET_ERR_TRAVERSAL_UNSUPPORTED
1561}
1562
1563// =========================================================================
1564// Streams
1565// =========================================================================
1566
1567#[derive(Deserialize, Default)]
1568struct StreamOpenConfig {
1569    /// `"reliable" | "fire_and_forget"`. Default `"fire_and_forget"`.
1570    reliability: Option<String>,
1571    /// Initial send-credit window in bytes. 0 disables backpressure.
1572    /// Default: `DEFAULT_STREAM_WINDOW_BYTES` (64 KB).
1573    window_bytes: Option<u32>,
1574    fairness_weight: Option<u8>,
1575}
1576
1577/// FFI handle for an open stream against a [`MeshNode`].
1578///
1579/// `HandleGuard`-protected. Without it, two distinct UAFs can
1580/// fire: `_node: Arc<MeshNode>` keeps the underlying node alive
1581/// but **not** the `MeshStreamHandle` Box itself —
1582/// `net_mesh_free(node_handle)` could deallocate the node
1583/// handle's box while `net_mesh_send` was deref'ing
1584/// `&*node_handle` for the `Arc::ptr_eq` check in
1585/// `handles_match`. The same hazard applies to this stream
1586/// handle's own box: a concurrent `net_mesh_stream_free` while
1587/// `net_mesh_send` was reading `sh.stream` / `sh._node` would
1588/// UAF the dropped fields. The guard closes both: the box stays
1589/// leaked across `_free`; ops register via `try_enter` and
1590/// `_free` quiesces them via `begin_free`.
1591pub struct MeshStreamHandle {
1592    stream: ManuallyDrop<CoreStream>,
1593    // Keep the node alive as long as the stream is alive so sends
1594    // don't race a concurrent shutdown.
1595    _node: ManuallyDrop<Arc<MeshNode>>,
1596    guard: HandleGuard,
1597}
1598
1599#[unsafe(no_mangle)]
1600pub unsafe extern "C" fn net_mesh_open_stream(
1601    handle: *mut MeshNodeHandle,
1602    peer_node_id: u64,
1603    stream_id: u64,
1604    config_json: *const c_char,
1605    out_stream: *mut *mut MeshStreamHandle,
1606) -> c_int {
1607    if handle.is_null() || out_stream.is_null() {
1608        return NetError::NullPointer.into();
1609    }
1610    let h = unsafe { &*handle };
1611    let _op = match h.guard.try_enter() {
1612        Some(op) => op,
1613        None => return NetError::ShuttingDown.into(),
1614    };
1615    let cfg_json: StreamOpenConfig = if config_json.is_null() {
1616        StreamOpenConfig::default()
1617    } else {
1618        let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
1619            return NetError::InvalidUtf8.into();
1620        };
1621        match serde_json::from_str(&s) {
1622            Ok(v) => v,
1623            Err(_) => return NetError::InvalidJson.into(),
1624        }
1625    };
1626    let reliability = match cfg_json.reliability.as_deref() {
1627        None | Some("fire_and_forget") => Reliability::FireAndForget,
1628        Some("reliable") => Reliability::Reliable,
1629        Some(_) => return NET_ERR_MESH_TRANSPORT,
1630    };
1631    let window = cfg_json.window_bytes.unwrap_or(DEFAULT_STREAM_WINDOW_BYTES);
1632    let weight = cfg_json.fairness_weight.unwrap_or(1);
1633    let cfg = StreamConfig::new()
1634        .with_reliability(reliability)
1635        .with_window_bytes(window)
1636        .with_fairness_weight(weight);
1637    match h.inner.open_stream(peer_node_id, stream_id, cfg) {
1638        Ok(stream) => {
1639            let node_clone: Arc<MeshNode> = Arc::clone(&h.inner);
1640            let sh = Box::new(MeshStreamHandle {
1641                stream: ManuallyDrop::new(stream),
1642                _node: ManuallyDrop::new(node_clone),
1643                guard: HandleGuard::new(),
1644            });
1645            unsafe {
1646                *out_stream = Box::into_raw(sh);
1647            }
1648            0
1649        }
1650        Err(e) => adapter_err_to_code(&e),
1651    }
1652}
1653
1654#[unsafe(no_mangle)]
1655pub unsafe extern "C" fn net_mesh_stream_free(handle: *mut MeshStreamHandle) {
1656    if handle.is_null() {
1657        return;
1658    }
1659    // Quiesce in-flight ops before dropping the inner. Box stays leaked.
1660    let h: &MeshStreamHandle = unsafe { &*handle };
1661    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
1662        // SAFETY: drained; sole writable reference.
1663        unsafe {
1664            // CoreStream is Copy/non-Drop; just take it out and let
1665            // it fall out of scope. The Arc<MeshNode> needs explicit
1666            // drop() to release its refcount.
1667            let _stream = ManuallyDrop::take(&mut (*handle).stream);
1668            let node = ManuallyDrop::take(&mut (*handle)._node);
1669            drop(node);
1670        }
1671    } else {
1672        tracing::warn!(
1673            "net_mesh_stream_free: in-flight ops did not drain within deadline; \
1674             leaking inner to avoid use-after-free"
1675        );
1676    }
1677}
1678
1679/// Collect an array of borrowed `(ptr, len)` pairs into a
1680/// `Vec<Bytes>`. Caller must keep the pointer / length arrays alive
1681/// for the duration of the C call.
1682///
1683/// Returns `None` if any per-entry pointer is null *with* a non-zero
1684/// length — the C contract has no "skip this entry" channel, so the
1685/// only correct response is to refuse the whole batch. A null pointer
1686/// with `len == 0` is treated as an empty payload (it never gets
1687/// dereferenced).
1688unsafe fn collect_payloads(
1689    payloads: *const *const u8,
1690    lens: *const usize,
1691    count: usize,
1692) -> Option<Vec<Bytes>> {
1693    let mut out = Vec::with_capacity(count);
1694    for i in 0..count {
1695        let ptr = *payloads.add(i);
1696        let len = *lens.add(i);
1697        if ptr.is_null() {
1698            if len == 0 {
1699                out.push(Bytes::new());
1700                continue;
1701            }
1702            return None;
1703        }
1704        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1705        // A caller passing a sign-extended `-1` would otherwise
1706        // immediately UB before any other validation runs.
1707        if len > isize::MAX as usize {
1708            return None;
1709        }
1710        let slice = std::slice::from_raw_parts(ptr, len);
1711        out.push(Bytes::copy_from_slice(slice));
1712    }
1713    Some(out)
1714}
1715
1716/// Ensure the supplied stream handle was created by the supplied
1717/// node handle. Without this check, `net_mesh_send` would happily
1718/// route bytes through whichever `MeshNode` was passed, even if the
1719/// stream belonged to a different one — silent cross-session
1720/// traffic. `Arc::ptr_eq` is O(1) and definitive: stream handles
1721/// cache the originating
1722/// node Arc in `_node` for exactly this purpose.
1723#[inline]
1724fn handles_match(sh: &MeshStreamHandle, nh: &MeshNodeHandle) -> bool {
1725    Arc::ptr_eq(&sh._node, &nh.inner)
1726}
1727
1728#[unsafe(no_mangle)]
1729pub unsafe extern "C" fn net_mesh_send(
1730    handle: *mut MeshStreamHandle,
1731    payloads: *const *const u8,
1732    lens: *const usize,
1733    count: usize,
1734    node_handle: *mut MeshNodeHandle,
1735) -> c_int {
1736    if handle.is_null() || node_handle.is_null() {
1737        return NetError::NullPointer.into();
1738    }
1739    if count > 0 && (payloads.is_null() || lens.is_null()) {
1740        return NetError::NullPointer.into();
1741    }
1742    let sh = unsafe { &*handle };
1743    let nh = unsafe { &*node_handle };
1744    // Gate both handles; either being freed concurrently would
1745    // otherwise UAF the inner deref below.
1746    let _sh_op = match sh.guard.try_enter() {
1747        Some(op) => op,
1748        None => return NetError::ShuttingDown.into(),
1749    };
1750    let _nh_op = match nh.guard.try_enter() {
1751        Some(op) => op,
1752        None => return NetError::ShuttingDown.into(),
1753    };
1754    if !handles_match(sh, nh) {
1755        return NetError::MismatchedHandles.into();
1756    }
1757    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1758        Some(v) => v,
1759        None => return NetError::NullPointer.into(),
1760    };
1761    let node = nh.inner.clone();
1762    let stream = sh.stream.clone();
1763    match block_on(async move { node.send_on_stream(&stream, &payloads).await }) {
1764        Ok(()) => 0,
1765        Err(e) => stream_err_to_code(&e),
1766    }
1767}
1768
1769#[unsafe(no_mangle)]
1770pub unsafe extern "C" fn net_mesh_send_with_retry(
1771    handle: *mut MeshStreamHandle,
1772    payloads: *const *const u8,
1773    lens: *const usize,
1774    count: usize,
1775    max_retries: u32,
1776    node_handle: *mut MeshNodeHandle,
1777) -> c_int {
1778    if handle.is_null() || node_handle.is_null() {
1779        return NetError::NullPointer.into();
1780    }
1781    if count > 0 && (payloads.is_null() || lens.is_null()) {
1782        return NetError::NullPointer.into();
1783    }
1784    let sh = unsafe { &*handle };
1785    let nh = unsafe { &*node_handle };
1786    // Gate both handles; either being freed concurrently would
1787    // otherwise UAF the inner deref below.
1788    let _sh_op = match sh.guard.try_enter() {
1789        Some(op) => op,
1790        None => return NetError::ShuttingDown.into(),
1791    };
1792    let _nh_op = match nh.guard.try_enter() {
1793        Some(op) => op,
1794        None => return NetError::ShuttingDown.into(),
1795    };
1796    if !handles_match(sh, nh) {
1797        return NetError::MismatchedHandles.into();
1798    }
1799    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1800        Some(v) => v,
1801        None => return NetError::NullPointer.into(),
1802    };
1803    let node = nh.inner.clone();
1804    let stream = sh.stream.clone();
1805    match block_on(async move {
1806        node.send_with_retry(&stream, &payloads, max_retries as usize)
1807            .await
1808    }) {
1809        Ok(()) => 0,
1810        Err(e) => stream_err_to_code(&e),
1811    }
1812}
1813
1814#[unsafe(no_mangle)]
1815pub unsafe extern "C" fn net_mesh_send_blocking(
1816    handle: *mut MeshStreamHandle,
1817    payloads: *const *const u8,
1818    lens: *const usize,
1819    count: usize,
1820    node_handle: *mut MeshNodeHandle,
1821) -> c_int {
1822    if handle.is_null() || node_handle.is_null() {
1823        return NetError::NullPointer.into();
1824    }
1825    if count > 0 && (payloads.is_null() || lens.is_null()) {
1826        return NetError::NullPointer.into();
1827    }
1828    let sh = unsafe { &*handle };
1829    let nh = unsafe { &*node_handle };
1830    // Gate both handles; either being freed concurrently would
1831    // otherwise UAF the inner deref below.
1832    let _sh_op = match sh.guard.try_enter() {
1833        Some(op) => op,
1834        None => return NetError::ShuttingDown.into(),
1835    };
1836    let _nh_op = match nh.guard.try_enter() {
1837        Some(op) => op,
1838        None => return NetError::ShuttingDown.into(),
1839    };
1840    if !handles_match(sh, nh) {
1841        return NetError::MismatchedHandles.into();
1842    }
1843    let payloads = match unsafe { collect_payloads(payloads, lens, count) } {
1844        Some(v) => v,
1845        None => return NetError::NullPointer.into(),
1846    };
1847    let node = nh.inner.clone();
1848    let stream = sh.stream.clone();
1849    match block_on(async move { node.send_blocking(&stream, &payloads).await }) {
1850        Ok(()) => 0,
1851        Err(e) => stream_err_to_code(&e),
1852    }
1853}
1854
1855#[derive(Serialize)]
1856struct StreamStatsJson {
1857    tx_seq: u64,
1858    rx_seq: u64,
1859    inbound_pending: u64,
1860    last_activity_ns: u64,
1861    active: bool,
1862    backpressure_events: u64,
1863    tx_credit_remaining: u32,
1864    tx_window: u32,
1865    credit_grants_received: u64,
1866    credit_grants_sent: u64,
1867}
1868
1869#[unsafe(no_mangle)]
1870pub unsafe extern "C" fn net_mesh_stream_stats(
1871    node_handle: *mut MeshNodeHandle,
1872    peer_node_id: u64,
1873    stream_id: u64,
1874    out_json: *mut *mut c_char,
1875    out_len: *mut usize,
1876) -> c_int {
1877    if node_handle.is_null() || out_json.is_null() || out_len.is_null() {
1878        return NetError::NullPointer.into();
1879    }
1880    let h = unsafe { &*node_handle };
1881    let _op = match h.guard.try_enter() {
1882        Some(op) => op,
1883        None => return NetError::ShuttingDown.into(),
1884    };
1885    match h.inner.stream_stats(peer_node_id, stream_id) {
1886        Some(s) => {
1887            let js = StreamStatsJson {
1888                tx_seq: s.tx_seq,
1889                rx_seq: s.rx_seq,
1890                inbound_pending: s.inbound_pending,
1891                last_activity_ns: s.last_activity_ns,
1892                active: s.active,
1893                backpressure_events: s.backpressure_events,
1894                tx_credit_remaining: s.tx_credit_remaining,
1895                tx_window: s.tx_window,
1896                credit_grants_received: s.credit_grants_received,
1897                credit_grants_sent: s.credit_grants_sent,
1898            };
1899            write_json_out(&js, out_json, out_len)
1900        }
1901        None => {
1902            // Encode `null` so Go can distinguish "no such stream"
1903            // from an error.
1904            write_string_out("null".to_string(), out_json, out_len)
1905        }
1906    }
1907}
1908
1909// =========================================================================
1910// Shard receive
1911// =========================================================================
1912
1913#[derive(Serialize)]
1914struct RecvEventJson {
1915    id: String,
1916    /// Base64 payload (binary-safe across the JSON boundary).
1917    payload_b64: String,
1918    insertion_ts: u64,
1919    shard_id: u16,
1920}
1921
1922#[unsafe(no_mangle)]
1923pub unsafe extern "C" fn net_mesh_recv_shard(
1924    handle: *mut MeshNodeHandle,
1925    shard_id: u16,
1926    limit: u32,
1927    out_json: *mut *mut c_char,
1928    out_len: *mut usize,
1929) -> c_int {
1930    if handle.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 node = h.inner.clone();
1939    let result = block_on(async move { node.poll_shard(shard_id, None, limit as usize).await });
1940    let result = match result {
1941        Ok(r) => r,
1942        Err(e) => return adapter_err_to_code(&e),
1943    };
1944    let events: Vec<RecvEventJson> = result
1945        .events
1946        .into_iter()
1947        .map(|e| RecvEventJson {
1948            id: e.id,
1949            payload_b64: encode_b64(&e.raw),
1950            insertion_ts: e.insertion_ts,
1951            shard_id: e.shard_id,
1952        })
1953        .collect();
1954    write_json_out(&events, out_json, out_len)
1955}
1956
1957fn encode_b64(bytes: &[u8]) -> String {
1958    // Small stdlib-free base64. Net already pulls in `base64` via
1959    // other deps, but a local encoder keeps this module independent.
1960    const ALPH: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1961    let mut s = String::with_capacity(bytes.len().div_ceil(3) * 4);
1962    let mut i = 0;
1963    while i + 3 <= bytes.len() {
1964        let chunk = &bytes[i..i + 3];
1965        s.push(ALPH[(chunk[0] >> 2) as usize] as char);
1966        s.push(ALPH[(((chunk[0] & 0b11) << 4) | (chunk[1] >> 4)) as usize] as char);
1967        s.push(ALPH[(((chunk[1] & 0b1111) << 2) | (chunk[2] >> 6)) as usize] as char);
1968        s.push(ALPH[(chunk[2] & 0b111111) as usize] as char);
1969        i += 3;
1970    }
1971    let rem = bytes.len() - i;
1972    if rem == 1 {
1973        let b = bytes[i];
1974        s.push(ALPH[(b >> 2) as usize] as char);
1975        s.push(ALPH[((b & 0b11) << 4) as usize] as char);
1976        s.push('=');
1977        s.push('=');
1978    } else if rem == 2 {
1979        let b0 = bytes[i];
1980        let b1 = bytes[i + 1];
1981        s.push(ALPH[(b0 >> 2) as usize] as char);
1982        s.push(ALPH[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
1983        s.push(ALPH[((b1 & 0b1111) << 2) as usize] as char);
1984        s.push('=');
1985    }
1986    s
1987}
1988
1989// =========================================================================
1990// Channels (distributed pub/sub)
1991// =========================================================================
1992
1993#[derive(Deserialize)]
1994struct ChannelConfigInput {
1995    name: String,
1996    visibility: Option<String>,
1997    reliable: Option<bool>,
1998    require_token: Option<bool>,
1999    /// Root(s) of trust for token authorization: hex-encoded 32-byte
2000    /// entity ids (64 hex chars each) whose signature may root a
2001    /// presented token chain. Setting this turns on token enforcement
2002    /// and anchors the channel; `require_token` alone (no roots) fails
2003    /// every authorization closed.
2004    token_roots: Option<Vec<String>>,
2005    priority: Option<u8>,
2006    max_rate_pps: Option<u32>,
2007    /// Capability filter restricting who may publish on this
2008    /// channel. Same POJO shape as `CapabilityFilter` (see
2009    /// `net_mesh_find_nodes`).
2010    publish_caps: Option<CapabilityFilterJson>,
2011    /// Capability filter restricting who may subscribe. Subscribers
2012    /// whose announced caps miss this filter are rejected with
2013    /// `NET_ERR_CHANNEL_AUTH`.
2014    subscribe_caps: Option<CapabilityFilterJson>,
2015}
2016
2017fn parse_visibility(s: &str) -> Option<InnerVisibility> {
2018    match s {
2019        "subnet-local" => Some(InnerVisibility::SubnetLocal),
2020        "parent-visible" => Some(InnerVisibility::ParentVisible),
2021        "exported" => Some(InnerVisibility::Exported),
2022        "global" => Some(InnerVisibility::Global),
2023        _ => None,
2024    }
2025}
2026
2027#[unsafe(no_mangle)]
2028pub unsafe extern "C" fn net_mesh_register_channel(
2029    handle: *mut MeshNodeHandle,
2030    config_json: *const c_char,
2031) -> c_int {
2032    if handle.is_null() || config_json.is_null() {
2033        return NetError::NullPointer.into();
2034    }
2035    let h = unsafe { &*handle };
2036    let _op = match h.guard.try_enter() {
2037        Some(op) => op,
2038        None => return NetError::ShuttingDown.into(),
2039    };
2040    let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
2041        return NetError::InvalidUtf8.into();
2042    };
2043    let input: ChannelConfigInput = match serde_json::from_str(&s) {
2044        Ok(v) => v,
2045        Err(_) => return NetError::InvalidJson.into(),
2046    };
2047    let name = match InnerChannelName::new(&input.name) {
2048        Ok(n) => n,
2049        Err(_) => return NET_ERR_CHANNEL,
2050    };
2051    let mut cfg = InnerChannelConfig::new(ChannelId::new(name));
2052    if let Some(v) = input.visibility {
2053        let Some(vis) = parse_visibility(&v) else {
2054            return NET_ERR_CHANNEL;
2055        };
2056        cfg = cfg.with_visibility(vis);
2057    }
2058    if let Some(r) = input.reliable {
2059        cfg = cfg.with_reliable(r);
2060    }
2061    if let Some(t) = input.require_token {
2062        cfg = cfg.with_require_token(t);
2063    }
2064    if let Some(roots) = input.token_roots {
2065        let mut parsed = Vec::with_capacity(roots.len());
2066        for hex_id in roots {
2067            let bytes = match hex::decode(&hex_id) {
2068                Ok(b) => b,
2069                Err(_) => return NET_ERR_CHANNEL,
2070            };
2071            let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else {
2072                return NET_ERR_CHANNEL;
2073            };
2074            parsed.push(EntityId::from_bytes(arr));
2075        }
2076        cfg = cfg.with_token_roots(parsed);
2077    }
2078    if let Some(p) = input.priority {
2079        cfg = cfg.with_priority(p);
2080    }
2081    if let Some(pps) = input.max_rate_pps {
2082        cfg = cfg.with_rate_limit(pps);
2083    }
2084    if let Some(filter_json) = input.publish_caps {
2085        cfg = cfg.with_publish_caps(capability_filter_from_json(filter_json));
2086    }
2087    if let Some(filter_json) = input.subscribe_caps {
2088        cfg = cfg.with_subscribe_caps(capability_filter_from_json(filter_json));
2089    }
2090    h.channel_configs.insert(cfg);
2091    0
2092}
2093
2094#[unsafe(no_mangle)]
2095pub unsafe extern "C" fn net_mesh_subscribe_channel(
2096    handle: *mut MeshNodeHandle,
2097    publisher_node_id: u64,
2098    channel: *const c_char,
2099) -> c_int {
2100    subscribe_or_unsubscribe(handle, publisher_node_id, channel, true)
2101}
2102
2103#[unsafe(no_mangle)]
2104pub unsafe extern "C" fn net_mesh_unsubscribe_channel(
2105    handle: *mut MeshNodeHandle,
2106    publisher_node_id: u64,
2107    channel: *const c_char,
2108) -> c_int {
2109    subscribe_or_unsubscribe(handle, publisher_node_id, channel, false)
2110}
2111
2112/// Subscribe with a serialized `PermissionToken` attached. Parses
2113/// the token client-side (rejecting malformed bytes with
2114/// `NET_ERR_TOKEN_INVALID_FORMAT`) before dispatching the request
2115/// to the publisher. Signature verification happens on the
2116/// publisher side; a tampered token will surface as
2117/// `NET_ERR_CHANNEL_AUTH` rather than a token error in this call.
2118#[unsafe(no_mangle)]
2119pub unsafe extern "C" fn net_mesh_subscribe_channel_with_token(
2120    handle: *mut MeshNodeHandle,
2121    publisher_node_id: u64,
2122    channel: *const c_char,
2123    token: *const u8,
2124    token_len: usize,
2125) -> c_int {
2126    if handle.is_null() || channel.is_null() || token.is_null() {
2127        return NetError::NullPointer.into();
2128    }
2129    let h = unsafe { &*handle };
2130    let _op = match h.guard.try_enter() {
2131        Some(op) => op,
2132        None => return NetError::ShuttingDown.into(),
2133    };
2134    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
2135        return NetError::InvalidUtf8.into();
2136    };
2137    let name = match InnerChannelName::new(&s) {
2138        Ok(n) => n,
2139        Err(_) => return NET_ERR_CHANNEL,
2140    };
2141    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2142    if token_len > isize::MAX as usize {
2143        return NetError::InvalidJson.into();
2144    }
2145    let slice = unsafe { std::slice::from_raw_parts(token, token_len) };
2146    let parsed = match PermissionToken::from_bytes(slice) {
2147        Ok(t) => t,
2148        Err(e) => return token_err_to_code(&e),
2149    };
2150    let node = h.inner.clone();
2151    match block_on(async move {
2152        node.subscribe_channel_with_token(publisher_node_id, name, parsed)
2153            .await
2154    }) {
2155        Ok(()) => 0,
2156        Err(e) => adapter_err_to_channel_code(&e),
2157    }
2158}
2159
2160fn subscribe_or_unsubscribe(
2161    handle: *mut MeshNodeHandle,
2162    publisher_node_id: u64,
2163    channel: *const c_char,
2164    subscribe: bool,
2165) -> c_int {
2166    if handle.is_null() || channel.is_null() {
2167        return NetError::NullPointer.into();
2168    }
2169    let h = unsafe { &*handle };
2170    let _op = match h.guard.try_enter() {
2171        Some(op) => op,
2172        None => return NetError::ShuttingDown.into(),
2173    };
2174    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
2175        return NetError::InvalidUtf8.into();
2176    };
2177    let name = match InnerChannelName::new(&s) {
2178        Ok(n) => n,
2179        Err(_) => return NET_ERR_CHANNEL,
2180    };
2181    let node = h.inner.clone();
2182    let outcome = if subscribe {
2183        block_on(async move { node.subscribe_channel(publisher_node_id, name).await })
2184    } else {
2185        block_on(async move { node.unsubscribe_channel(publisher_node_id, name).await })
2186    };
2187    match outcome {
2188        Ok(()) => 0,
2189        Err(e) => adapter_err_to_channel_code(&e),
2190    }
2191}
2192
2193fn adapter_err_to_channel_code(err: &AdapterError) -> c_int {
2194    if let AdapterError::Connection(msg) = err {
2195        let prefix = "membership request rejected: ";
2196        if let Some(tail) = msg.strip_prefix(prefix) {
2197            if tail.trim() == "Some(Unauthorized)" {
2198                return NET_ERR_CHANNEL_AUTH;
2199            }
2200        }
2201    }
2202    NET_ERR_CHANNEL
2203}
2204
2205#[derive(Deserialize, Default)]
2206struct PublishConfigInput {
2207    reliability: Option<String>,
2208    on_failure: Option<String>,
2209    max_inflight: Option<u32>,
2210}
2211
2212#[derive(Serialize)]
2213struct PublishReportJson {
2214    attempted: u32,
2215    delivered: u32,
2216    errors: Vec<PublishFailureJson>,
2217}
2218
2219#[derive(Serialize)]
2220struct PublishFailureJson {
2221    node_id: u64,
2222    message: String,
2223}
2224
2225fn to_publish_report_json(r: InnerPublishReport) -> PublishReportJson {
2226    PublishReportJson {
2227        attempted: r.attempted as u32,
2228        delivered: r.delivered as u32,
2229        errors: r
2230            .errors
2231            .into_iter()
2232            .map(|(id, e)| PublishFailureJson {
2233                node_id: id,
2234                message: format!("{}", e),
2235            })
2236            .collect(),
2237    }
2238}
2239
2240#[unsafe(no_mangle)]
2241pub unsafe extern "C" fn net_mesh_publish(
2242    handle: *mut MeshNodeHandle,
2243    channel: *const c_char,
2244    payload: *const u8,
2245    len: usize,
2246    config_json: *const c_char,
2247    out_json: *mut *mut c_char,
2248    out_len: *mut usize,
2249) -> c_int {
2250    if handle.is_null() || channel.is_null() || out_json.is_null() || out_len.is_null() {
2251        return NetError::NullPointer.into();
2252    }
2253    let h = unsafe { &*handle };
2254    let _op = match h.guard.try_enter() {
2255        Some(op) => op,
2256        None => return NetError::ShuttingDown.into(),
2257    };
2258    let Some(ch) = (unsafe { c_str_to_string(channel) }) else {
2259        return NetError::InvalidUtf8.into();
2260    };
2261    let name = match InnerChannelName::new(&ch) {
2262        Ok(n) => n,
2263        Err(_) => return NET_ERR_CHANNEL,
2264    };
2265    let cfg_in: PublishConfigInput = if config_json.is_null() {
2266        PublishConfigInput::default()
2267    } else {
2268        let Some(s) = (unsafe { c_str_to_string(config_json) }) else {
2269            return NetError::InvalidUtf8.into();
2270        };
2271        match serde_json::from_str(&s) {
2272            Ok(v) => v,
2273            Err(_) => return NetError::InvalidJson.into(),
2274        }
2275    };
2276    let reliability = match cfg_in.reliability.as_deref() {
2277        None | Some("fire_and_forget") => Reliability::FireAndForget,
2278        Some("reliable") => Reliability::Reliable,
2279        Some(_) => return NET_ERR_CHANNEL,
2280    };
2281    let on_failure = match cfg_in.on_failure.as_deref() {
2282        None | Some("best_effort") => InnerOnFailure::BestEffort,
2283        Some("fail_fast") => InnerOnFailure::FailFast,
2284        Some("collect") => InnerOnFailure::Collect,
2285        Some(_) => return NET_ERR_CHANNEL,
2286    };
2287    let max_inflight = cfg_in.max_inflight.unwrap_or(32) as usize;
2288    let publish_cfg = InnerPublishConfig {
2289        reliability,
2290        on_failure,
2291        max_inflight,
2292    };
2293    let publisher = ChannelPublisher::new(name, publish_cfg);
2294
2295    // Payload may be NULL only when len == 0.
2296    let bytes = if len == 0 {
2297        Bytes::new()
2298    } else if payload.is_null() {
2299        return NetError::NullPointer.into();
2300    } else if len > isize::MAX as usize {
2301        // `slice::from_raw_parts` requires `len <= isize::MAX`.
2302        return NetError::InvalidJson.into();
2303    } else {
2304        Bytes::copy_from_slice(unsafe { std::slice::from_raw_parts(payload, len) })
2305    };
2306
2307    let node = h.inner.clone();
2308    match block_on(async move { node.publish(&publisher, bytes).await }) {
2309        Ok(report) => {
2310            let js = to_publish_report_json(report);
2311            write_json_out(&js, out_json, out_len)
2312        }
2313        Err(e) => adapter_err_to_channel_code(&e),
2314    }
2315}
2316
2317// =========================================================================
2318// Identity + permission tokens
2319// =========================================================================
2320
2321/// Opaque handle holding an ed25519 keypair plus a local
2322/// `TokenCache`. Matches the PyO3 / NAPI `Identity` pyclass layout —
2323/// cheap to clone (both fields are `Arc`s inside the core), and the
2324/// cache is owned by the handle rather than shared across peers.
2325///
2326/// Same `HandleGuard` recipe as the cortex handles (see
2327/// `super::handle_guard` for soundness). Box stays leaked across
2328/// `_free`; inner Arcs live in `ManuallyDrop` so the free can
2329/// take and drop them after quiescing in-flight ops.
2330pub struct IdentityHandle {
2331    keypair: ManuallyDrop<Arc<EntityKeypair>>,
2332    cache: ManuallyDrop<Arc<TokenCache>>,
2333    guard: HandleGuard,
2334}
2335
2336/// Allocate and copy `src` into a freshly allocated buffer owned by
2337/// `std::alloc::alloc` with a layout of `Layout::array::<u8>(len)`.
2338/// The matching `net_free_bytes` must deallocate with the same layout
2339/// — both sides pin the capacity to `len`, so there is no reliance on
2340/// `Vec::shrink_to_fit` producing `capacity == len` (which is not
2341/// guaranteed by the allocator API).
2342///
2343/// Returns `NetError::NullPointer` (the FFI-safe sentinel) if either
2344/// out-pointer is null. Every current call site filters nulls at the
2345/// public `extern "C"` entry before reaching here, so this check is
2346/// defence-in-depth — its purpose is to make `alloc_bytes` safe to
2347/// reuse from future call sites without retracing the null-handling
2348/// contract.
2349fn alloc_bytes(src: &[u8], out_ptr: *mut *mut u8, out_len: *mut usize) -> c_int {
2350    if out_ptr.is_null() || out_len.is_null() {
2351        return NetError::NullPointer.into();
2352    }
2353    let len = src.len();
2354    if len == 0 {
2355        unsafe {
2356            *out_ptr = std::ptr::null_mut();
2357            *out_len = 0;
2358        }
2359        return 0;
2360    }
2361    // `Layout::array::<u8>(len)` rejects `len > isize::MAX` (the
2362    // documented bound — NOT `usize::MAX`). The current call
2363    // sites stay well under that limit because `to_bytes()`
2364    // produces token-sized payloads, so the failure mode is
2365    // unreachable today; defending against it here also keeps the
2366    // helper safe to reuse from non-token code paths in the
2367    // future. A panic here would unwind across the surrounding
2368    // `extern "C"` boundary.
2369    let layout = match std::alloc::Layout::array::<u8>(len) {
2370        Ok(l) => l,
2371        // Reuse the closest sentinel we have — `NET_ERR_IDENTITY`
2372        // covers the only call sites today (token/identity helpers
2373        // that delegate to `alloc_bytes`). The negative integer is
2374        // an FFI-safe error code; the alternative `panic!` would
2375        // unwind across `extern "C"`.
2376        Err(_) => return NET_ERR_IDENTITY,
2377    };
2378    let ptr = unsafe { std::alloc::alloc(layout) };
2379    if ptr.is_null() {
2380        std::alloc::handle_alloc_error(layout);
2381    }
2382    unsafe {
2383        std::ptr::copy_nonoverlapping(src.as_ptr(), ptr, len);
2384        *out_ptr = ptr;
2385        *out_len = len;
2386    }
2387    0
2388}
2389
2390/// Free a byte buffer allocated by the Rust side (tokens, entity ids
2391/// returned by reference, etc.). The `len` argument MUST match the
2392/// length returned by the allocating call — the buffer was allocated
2393/// with `Layout::array::<u8>(len)` and is freed with the same layout.
2394///
2395/// We silently no-op on `len > isize::MAX`: the allocation that
2396/// produced `ptr` could not have come from this process under that
2397/// layout (the allocator would have rejected the matching
2398/// `alloc`), so any such call is already memory-corruption
2399/// territory and the safest response is to abandon the free rather
2400/// than unwind. `net_free_bytes` is `extern "C"` with no
2401/// `catch_unwind` shim, so a panic would unwind across the FFI
2402/// boundary into a C / Go-cgo / NAPI / PyO3 caller — undefined
2403/// behaviour.
2404#[unsafe(no_mangle)]
2405pub unsafe extern "C" fn net_free_bytes(ptr: *mut u8, len: usize) {
2406    if ptr.is_null() || len == 0 {
2407        return;
2408    }
2409    // Reject `len > isize::MAX` before calling `Layout::array`. The
2410    // allocating call paired with this free uses the same layout and
2411    // would itself have failed for any such `len`, so a buffer
2412    // matching this `len` cannot have come from us; treat as a no-op
2413    // rather than panic across the FFI boundary.
2414    let layout = match std::alloc::Layout::array::<u8>(len) {
2415        Ok(l) => l,
2416        Err(_) => return,
2417    };
2418    unsafe {
2419        std::alloc::dealloc(ptr, layout);
2420    }
2421}
2422
2423fn entity_id_from_bytes(bytes: *const u8, len: usize) -> Option<EntityId> {
2424    if bytes.is_null() || len != 32 {
2425        return None;
2426    }
2427    let slice = unsafe { std::slice::from_raw_parts(bytes, 32) };
2428    let mut arr = [0u8; 32];
2429    arr.copy_from_slice(slice);
2430    Some(EntityId::from_bytes(arr))
2431}
2432
2433fn parse_scope_list(raw: &str) -> Option<TokenScope> {
2434    // JSON array of string scope names — same shape as PyO3's
2435    // `Vec<String>` parsing. Keeps the ABI aligned to the Python /
2436    // NAPI surfaces for round-trip fixtures.
2437    let values: Vec<String> = serde_json::from_str(raw).ok()?;
2438    let mut acc = TokenScope::NONE;
2439    for s in &values {
2440        acc = acc.union(match s.as_str() {
2441            "publish" => TokenScope::PUBLISH,
2442            "subscribe" => TokenScope::SUBSCRIBE,
2443            "admin" => TokenScope::ADMIN,
2444            "delegate" => TokenScope::DELEGATE,
2445            _ => return None,
2446        });
2447    }
2448    Some(acc)
2449}
2450
2451fn scope_to_strings(scope: TokenScope) -> Vec<&'static str> {
2452    let mut out = Vec::new();
2453    if scope.contains(TokenScope::PUBLISH) {
2454        out.push("publish");
2455    }
2456    if scope.contains(TokenScope::SUBSCRIBE) {
2457        out.push("subscribe");
2458    }
2459    if scope.contains(TokenScope::ADMIN) {
2460        out.push("admin");
2461    }
2462    if scope.contains(TokenScope::DELEGATE) {
2463        out.push("delegate");
2464    }
2465    out
2466}
2467
2468fn channel_name_to_hash(channel: &str) -> Option<ChannelHash> {
2469    InnerChannelName::new(channel).ok().map(|n| n.hash())
2470}
2471
2472/// Generate a fresh ed25519 identity. Writes an owned handle to
2473/// `*out_handle`. Free via `net_identity_free`.
2474#[unsafe(no_mangle)]
2475pub unsafe extern "C" fn net_identity_generate(out_handle: *mut *mut IdentityHandle) -> c_int {
2476    if out_handle.is_null() {
2477        return NetError::NullPointer.into();
2478    }
2479    let handle = Box::new(IdentityHandle {
2480        keypair: ManuallyDrop::new(Arc::new(EntityKeypair::generate())),
2481        cache: ManuallyDrop::new(Arc::new(TokenCache::new())),
2482        guard: HandleGuard::new(),
2483    });
2484    unsafe {
2485        *out_handle = Box::into_raw(handle);
2486    }
2487    0
2488}
2489
2490/// Construct an identity from a caller-owned 32-byte ed25519 seed.
2491/// Installs a fresh, empty `TokenCache` — reinstall tokens via
2492/// `net_identity_install_token` after rehydrating from disk.
2493#[unsafe(no_mangle)]
2494pub unsafe extern "C" fn net_identity_from_seed(
2495    seed: *const u8,
2496    seed_len: usize,
2497    out_handle: *mut *mut IdentityHandle,
2498) -> c_int {
2499    if seed.is_null() || out_handle.is_null() {
2500        return NetError::NullPointer.into();
2501    }
2502    if seed_len != 32 {
2503        return NET_ERR_IDENTITY;
2504    }
2505    let mut arr = [0u8; 32];
2506    arr.copy_from_slice(unsafe { std::slice::from_raw_parts(seed, 32) });
2507    let handle = Box::new(IdentityHandle {
2508        keypair: ManuallyDrop::new(Arc::new(EntityKeypair::from_bytes(arr))),
2509        cache: ManuallyDrop::new(Arc::new(TokenCache::new())),
2510        guard: HandleGuard::new(),
2511    });
2512    unsafe {
2513        *out_handle = Box::into_raw(handle);
2514    }
2515    0
2516}
2517
2518#[unsafe(no_mangle)]
2519pub unsafe extern "C" fn net_identity_free(handle: *mut IdentityHandle) {
2520    if handle.is_null() {
2521        return;
2522    }
2523    // Quiesce in-flight ops before dropping inner; box leaked.
2524    let h: &IdentityHandle = unsafe { &*handle };
2525    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
2526        // SAFETY: drained; sole writable reference.
2527        unsafe {
2528            let mh = &mut *handle;
2529            let kp = ManuallyDrop::take(&mut mh.keypair);
2530            let cache = ManuallyDrop::take(&mut mh.cache);
2531            drop(kp);
2532            drop(cache);
2533        }
2534    } else {
2535        tracing::warn!(
2536            "net_identity_free: in-flight ops did not drain within deadline; \
2537             leaking inner to avoid use-after-free"
2538        );
2539    }
2540}
2541
2542/// Write the 32-byte ed25519 seed into `out[32]`. Caller must pass
2543/// a buffer of at least 32 bytes.
2544#[unsafe(no_mangle)]
2545pub unsafe extern "C" fn net_identity_to_seed(handle: *mut IdentityHandle, out: *mut u8) -> c_int {
2546    if handle.is_null() || out.is_null() {
2547        return NetError::NullPointer.into();
2548    }
2549    let h = unsafe { &*handle };
2550    let _op = match h.guard.try_enter() {
2551        Some(op) => op,
2552        None => return NetError::ShuttingDown.into(),
2553    };
2554    let seed = h.keypair.secret_bytes();
2555    unsafe {
2556        std::ptr::copy_nonoverlapping(seed.as_ptr(), out, 32);
2557    }
2558    0
2559}
2560
2561/// Write the 32-byte entity id into `out[32]`.
2562#[unsafe(no_mangle)]
2563pub unsafe extern "C" fn net_identity_entity_id(
2564    handle: *mut IdentityHandle,
2565    out: *mut u8,
2566) -> c_int {
2567    if handle.is_null() || out.is_null() {
2568        return NetError::NullPointer.into();
2569    }
2570    let h = unsafe { &*handle };
2571    let _op = match h.guard.try_enter() {
2572        Some(op) => op,
2573        None => return NetError::ShuttingDown.into(),
2574    };
2575    let id = h.keypair.entity_id().as_bytes();
2576    unsafe {
2577        std::ptr::copy_nonoverlapping(id.as_ptr(), out, 32);
2578    }
2579    0
2580}
2581
2582#[unsafe(no_mangle)]
2583pub unsafe extern "C" fn net_identity_node_id(handle: *mut IdentityHandle) -> u64 {
2584    if handle.is_null() {
2585        return 0;
2586    }
2587    let h = unsafe { &*handle };
2588    // Returns 0 on shutting-down — same shape as absent-handle.
2589    let _op = match h.guard.try_enter() {
2590        Some(op) => op,
2591        None => return 0,
2592    };
2593    h.keypair.node_id()
2594}
2595
2596#[unsafe(no_mangle)]
2597pub unsafe extern "C" fn net_identity_origin_hash(handle: *mut IdentityHandle) -> u64 {
2598    if handle.is_null() {
2599        return 0;
2600    }
2601    let h = unsafe { &*handle };
2602    // Returns 0 on shutting-down — same shape as absent-handle.
2603    let _op = match h.guard.try_enter() {
2604        Some(op) => op,
2605        None => return 0,
2606    };
2607    h.keypair.origin_hash()
2608}
2609
2610/// Sign `msg[len]` with the identity's ed25519 secret key. Writes a
2611/// 64-byte signature into `out_sig[64]`.
2612#[unsafe(no_mangle)]
2613pub unsafe extern "C" fn net_identity_sign(
2614    handle: *mut IdentityHandle,
2615    msg: *const u8,
2616    len: usize,
2617    out_sig: *mut u8,
2618) -> c_int {
2619    if handle.is_null() || out_sig.is_null() {
2620        return NetError::NullPointer.into();
2621    }
2622    if len > 0 && msg.is_null() {
2623        return NetError::NullPointer.into();
2624    }
2625    let h = unsafe { &*handle };
2626    let _op = match h.guard.try_enter() {
2627        Some(op) => op,
2628        None => return NetError::ShuttingDown.into(),
2629    };
2630    let slice = if len == 0 {
2631        &[][..]
2632    } else if len > isize::MAX as usize {
2633        // `slice::from_raw_parts` requires `len <= isize::MAX`.
2634        return NetError::InvalidJson.into();
2635    } else {
2636        unsafe { std::slice::from_raw_parts(msg, len) }
2637    };
2638    let sig = h.keypair.sign(slice).to_bytes();
2639    unsafe {
2640        std::ptr::copy_nonoverlapping(sig.as_ptr(), out_sig, 64);
2641    }
2642    0
2643}
2644
2645/// Issue a token to `subject`. Writes a newly-allocated blob to
2646/// `*out_token`; caller frees via `net_free_bytes(ptr, *out_len)`.
2647#[unsafe(no_mangle)]
2648pub unsafe extern "C" fn net_identity_issue_token(
2649    signer: *mut IdentityHandle,
2650    subject: *const u8,
2651    subject_len: usize,
2652    scope_json: *const c_char,
2653    channel: *const c_char,
2654    ttl_seconds: u32,
2655    delegation_depth: u8,
2656    out_token: *mut *mut u8,
2657    out_token_len: *mut usize,
2658) -> c_int {
2659    if signer.is_null() || out_token.is_null() || out_token_len.is_null() {
2660        return NetError::NullPointer.into();
2661    }
2662    let Some(subject_id) = entity_id_from_bytes(subject, subject_len) else {
2663        return NET_ERR_IDENTITY;
2664    };
2665    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
2666        return NetError::InvalidUtf8.into();
2667    };
2668    let Some(scope) = parse_scope_list(&scope_s) else {
2669        return NET_ERR_IDENTITY;
2670    };
2671    let Some(channel_s) = (unsafe { c_str_to_string(channel) }) else {
2672        return NetError::InvalidUtf8.into();
2673    };
2674    let Some(channel_hash) = channel_name_to_hash(&channel_s) else {
2675        return NET_ERR_IDENTITY;
2676    };
2677    let h = unsafe { &*signer };
2678    // Gate before touching `h.keypair` (which lives in
2679    // `ManuallyDrop`). A concurrent `net_identity_free` would
2680    // otherwise drop the keypair while `try_issue` borrows it.
2681    let _op = match h.guard.try_enter() {
2682        Some(op) => op,
2683        None => return NetError::ShuttingDown.into(),
2684    };
2685    // Route through `try_issue` so a public-only signer keypair
2686    // (post-migration zeroize, etc.) surfaces as
2687    // `TokenError::ReadOnly` → `NET_ERR_IDENTITY` instead of
2688    // panic-unwinding across this `extern "C"` frame into the
2689    // caller's binding.
2690    let token = match PermissionToken::try_issue(
2691        &h.keypair,
2692        subject_id,
2693        scope,
2694        channel_hash,
2695        u64::from(ttl_seconds),
2696        delegation_depth,
2697    ) {
2698        Ok(t) => t,
2699        Err(e) => return token_err_to_code(&e),
2700    };
2701    alloc_bytes(&token.to_bytes(), out_token, out_token_len)
2702}
2703
2704/// Install a token received from another issuer. Signature +
2705/// structural checks run on insert; malformed or tampered tokens
2706/// return the relevant `NET_ERR_TOKEN_*` code.
2707#[unsafe(no_mangle)]
2708pub unsafe extern "C" fn net_identity_install_token(
2709    handle: *mut IdentityHandle,
2710    token: *const u8,
2711    len: usize,
2712) -> c_int {
2713    if handle.is_null() || token.is_null() {
2714        return NetError::NullPointer.into();
2715    }
2716    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2717    if len > isize::MAX as usize {
2718        return NetError::InvalidJson.into();
2719    }
2720    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2721    let parsed = match PermissionToken::from_bytes(slice) {
2722        Ok(t) => t,
2723        Err(e) => return token_err_to_code(&e),
2724    };
2725    let h = unsafe { &*handle };
2726    let _op = match h.guard.try_enter() {
2727        Some(op) => op,
2728        None => return NetError::ShuttingDown.into(),
2729    };
2730    match h.cache.insert(parsed) {
2731        Ok(()) => 0,
2732        Err(e) => token_err_to_code(&e),
2733    }
2734}
2735
2736/// Look up a cached token by `(subject, channel)`. Writes a newly-
2737/// allocated blob to `*out_token` on hit; writes `NULL` / `0` on
2738/// miss. Caller must always free on hit via `net_free_bytes`.
2739#[unsafe(no_mangle)]
2740pub unsafe extern "C" fn net_identity_lookup_token(
2741    handle: *mut IdentityHandle,
2742    subject: *const u8,
2743    subject_len: usize,
2744    channel: *const c_char,
2745    out_token: *mut *mut u8,
2746    out_token_len: *mut usize,
2747) -> c_int {
2748    if handle.is_null() || out_token.is_null() || out_token_len.is_null() {
2749        return NetError::NullPointer.into();
2750    }
2751    let Some(subject_id) = entity_id_from_bytes(subject, subject_len) else {
2752        return NET_ERR_IDENTITY;
2753    };
2754    let Some(channel_s) = (unsafe { c_str_to_string(channel) }) else {
2755        return NetError::InvalidUtf8.into();
2756    };
2757    let Some(channel_hash) = channel_name_to_hash(&channel_s) else {
2758        return NET_ERR_IDENTITY;
2759    };
2760    let h = unsafe { &*handle };
2761    let _op = match h.guard.try_enter() {
2762        Some(op) => op,
2763        None => return NetError::ShuttingDown.into(),
2764    };
2765    match h.cache.get(&subject_id, channel_hash) {
2766        Some(token) => alloc_bytes(&token.to_bytes(), out_token, out_token_len),
2767        None => {
2768            unsafe {
2769                *out_token = std::ptr::null_mut();
2770                *out_token_len = 0;
2771            }
2772            0
2773        }
2774    }
2775}
2776
2777#[unsafe(no_mangle)]
2778pub unsafe extern "C" fn net_identity_token_cache_len(handle: *mut IdentityHandle) -> u32 {
2779    if handle.is_null() {
2780        return 0;
2781    }
2782    let h = unsafe { &*handle };
2783    // Returns 0 on shutting-down — same shape as absent-handle.
2784    let _op = match h.guard.try_enter() {
2785        Some(op) => op,
2786        None => return 0,
2787    };
2788    h.cache.len() as u32
2789}
2790
2791// -------------------------------------------------------------------------
2792// Module-level token helpers
2793// -------------------------------------------------------------------------
2794
2795#[derive(Serialize)]
2796struct ParsedTokenJson {
2797    issuer_hex: String,
2798    subject_hex: String,
2799    scope: Vec<&'static str>,
2800    channel_hash: ChannelHash,
2801    not_before: u64,
2802    not_after: u64,
2803    delegation_depth: u8,
2804    nonce: u64,
2805    signature_hex: String,
2806}
2807
2808/// Parse a serialized `PermissionToken` into a JSON dict. Fields are
2809/// hex-encoded on the wire (`issuer_hex`, `subject_hex`,
2810/// `signature_hex`) so the JSON round-trips cleanly. Binary variants
2811/// live on the `Identity` handle.
2812#[unsafe(no_mangle)]
2813pub unsafe extern "C" fn net_parse_token(
2814    token: *const u8,
2815    len: usize,
2816    out_json: *mut *mut c_char,
2817    out_len: *mut usize,
2818) -> c_int {
2819    if token.is_null() || out_json.is_null() || out_len.is_null() {
2820        return NetError::NullPointer.into();
2821    }
2822    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2823    if len > isize::MAX as usize {
2824        return NetError::InvalidJson.into();
2825    }
2826    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2827    let parsed = match PermissionToken::from_bytes(slice) {
2828        Ok(t) => t,
2829        Err(e) => return token_err_to_code(&e),
2830    };
2831    let out = ParsedTokenJson {
2832        issuer_hex: hex::encode(parsed.issuer.as_bytes()),
2833        subject_hex: hex::encode(parsed.subject.as_bytes()),
2834        scope: scope_to_strings(parsed.scope),
2835        channel_hash: parsed.channel_hash,
2836        not_before: parsed.not_before,
2837        not_after: parsed.not_after,
2838        delegation_depth: parsed.delegation_depth,
2839        nonce: parsed.nonce,
2840        signature_hex: hex::encode(parsed.signature),
2841    };
2842    write_json_out(&out, out_json, out_len)
2843}
2844
2845/// Verify a serialized token's ed25519 signature. Writes `1` for
2846/// valid / `0` for tampered-or-wrong-subject. Time-bound validity is
2847/// a separate check — see `net_token_is_expired`.
2848#[unsafe(no_mangle)]
2849pub unsafe extern "C" fn net_verify_token(
2850    token: *const u8,
2851    len: usize,
2852    out_ok: *mut c_int,
2853) -> c_int {
2854    if token.is_null() || out_ok.is_null() {
2855        return NetError::NullPointer.into();
2856    }
2857    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2858    if len > isize::MAX as usize {
2859        return NetError::InvalidJson.into();
2860    }
2861    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2862    let parsed = match PermissionToken::from_bytes(slice) {
2863        Ok(t) => t,
2864        Err(e) => return token_err_to_code(&e),
2865    };
2866    unsafe {
2867        *out_ok = if parsed.verify().is_ok() { 1 } else { 0 };
2868    }
2869    0
2870}
2871
2872/// Writes `1` to `*out_expired` if the token's `not_after` has
2873/// passed; `0` otherwise. Pure time check — a tampered-but-expired
2874/// token still reports `1`. Use `net_verify_token` for signature
2875/// integrity.
2876#[unsafe(no_mangle)]
2877pub unsafe extern "C" fn net_token_is_expired(
2878    token: *const u8,
2879    len: usize,
2880    out_expired: *mut c_int,
2881) -> c_int {
2882    if token.is_null() || out_expired.is_null() {
2883        return NetError::NullPointer.into();
2884    }
2885    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2886    if len > isize::MAX as usize {
2887        return NetError::InvalidJson.into();
2888    }
2889    let slice = unsafe { std::slice::from_raw_parts(token, len) };
2890    let parsed = match PermissionToken::from_bytes(slice) {
2891        Ok(t) => t,
2892        Err(e) => return token_err_to_code(&e),
2893    };
2894    unsafe {
2895        *out_expired = if parsed.is_expired() { 1 } else { 0 };
2896    }
2897    0
2898}
2899
2900/// Delegate a token to a new subject. Returns the child token blob;
2901/// caller frees via `net_free_bytes`.
2902#[unsafe(no_mangle)]
2903pub unsafe extern "C" fn net_delegate_token(
2904    signer: *mut IdentityHandle,
2905    parent: *const u8,
2906    parent_len: usize,
2907    new_subject: *const u8,
2908    new_subject_len: usize,
2909    restricted_scope_json: *const c_char,
2910    out_token: *mut *mut u8,
2911    out_token_len: *mut usize,
2912) -> c_int {
2913    if signer.is_null()
2914        || parent.is_null()
2915        || new_subject.is_null()
2916        || restricted_scope_json.is_null()
2917        || out_token.is_null()
2918        || out_token_len.is_null()
2919    {
2920        return NetError::NullPointer.into();
2921    }
2922    // `slice::from_raw_parts` requires `len <= isize::MAX`.
2923    if parent_len > isize::MAX as usize {
2924        return NetError::InvalidJson.into();
2925    }
2926    let parent_slice = unsafe { std::slice::from_raw_parts(parent, parent_len) };
2927    let parent_tok = match PermissionToken::from_bytes(parent_slice) {
2928        Ok(t) => t,
2929        Err(e) => return token_err_to_code(&e),
2930    };
2931    let Some(subject_id) = entity_id_from_bytes(new_subject, new_subject_len) else {
2932        return NET_ERR_IDENTITY;
2933    };
2934    let Some(scope_s) = (unsafe { c_str_to_string(restricted_scope_json) }) else {
2935        return NetError::InvalidUtf8.into();
2936    };
2937    let Some(scope) = parse_scope_list(&scope_s) else {
2938        return NET_ERR_IDENTITY;
2939    };
2940    let h = unsafe { &*signer };
2941    // Gate before touching `h.keypair` (in `ManuallyDrop`).
2942    // A concurrent `net_identity_free` would otherwise drop the
2943    // keypair while `parent_tok.delegate` borrows it.
2944    let _op = match h.guard.try_enter() {
2945        Some(op) => op,
2946        None => return NetError::ShuttingDown.into(),
2947    };
2948    match parent_tok.delegate(&h.keypair, subject_id, scope) {
2949        Ok(child) => alloc_bytes(&child.to_bytes(), out_token, out_token_len),
2950        Err(e) => token_err_to_code(&e),
2951    }
2952}
2953
2954/// Hash a channel name to its canonical 64-bit [`ChannelHash`]
2955/// (substrate-wide ACL / config / storage key). The 16-bit wire
2956/// hash used by `NetHeader::channel_hash` is the low 16 bits of
2957/// the returned value. Returns `NET_ERR_IDENTITY` for invalid names.
2958#[unsafe(no_mangle)]
2959pub unsafe extern "C" fn net_channel_hash(channel: *const c_char, out_hash: *mut u64) -> c_int {
2960    if channel.is_null() || out_hash.is_null() {
2961        return NetError::NullPointer.into();
2962    }
2963    let Some(s) = (unsafe { c_str_to_string(channel) }) else {
2964        return NetError::InvalidUtf8.into();
2965    };
2966    let Some(hash) = channel_name_to_hash(&s) else {
2967        return NET_ERR_IDENTITY;
2968    };
2969    unsafe {
2970        *out_hash = hash;
2971    }
2972    0
2973}
2974
2975// =========================================================================
2976// Capabilities (announce / find_nodes)
2977// =========================================================================
2978
2979// Local alias to keep the capability helpers out of the mesh module's
2980// import list when the Go surface doesn't need them.
2981use crate::adapter::net::behavior::capability::{
2982    AcceleratorInfo, AcceleratorType, CapabilityFilter, CapabilitySet, GpuInfo, GpuVendor,
2983    HardwareCapabilities, Modality, ModelCapability, ResourceLimits, SoftwareCapabilities,
2984    ToolCapability, TAG_SCOPE_REGION_PREFIX, TAG_SCOPE_SUBNET_LOCAL, TAG_SCOPE_TENANT_PREFIX,
2985};
2986
2987// ----- enum helpers (byte-for-byte mirrors of PyO3/NAPI) ---------------------
2988
2989fn parse_gpu_vendor_cap(s: &str) -> GpuVendor {
2990    match s.to_ascii_lowercase().as_str() {
2991        "nvidia" => GpuVendor::Nvidia,
2992        "amd" => GpuVendor::Amd,
2993        "intel" => GpuVendor::Intel,
2994        "apple" => GpuVendor::Apple,
2995        "qualcomm" => GpuVendor::Qualcomm,
2996        _ => GpuVendor::Unknown,
2997    }
2998}
2999
3000fn gpu_vendor_to_string_cap(v: GpuVendor) -> &'static str {
3001    match v {
3002        GpuVendor::Nvidia => "nvidia",
3003        GpuVendor::Amd => "amd",
3004        GpuVendor::Intel => "intel",
3005        GpuVendor::Apple => "apple",
3006        GpuVendor::Qualcomm => "qualcomm",
3007        GpuVendor::Unknown => "unknown",
3008    }
3009}
3010
3011fn parse_modality_cap(s: &str) -> Option<Modality> {
3012    match s.to_ascii_lowercase().as_str() {
3013        "text" => Some(Modality::Text),
3014        "image" => Some(Modality::Image),
3015        "audio" => Some(Modality::Audio),
3016        "video" => Some(Modality::Video),
3017        "code" => Some(Modality::Code),
3018        "embedding" => Some(Modality::Embedding),
3019        "tool-use" | "tool_use" | "tooluse" => Some(Modality::ToolUse),
3020        // Pre-fix unknown strings (typos) silently fell back to
3021        // `Modality::Text`. For announce-capabilities that meant
3022        // a node advertised "Text" support it didn't actually
3023        // have; for find-nodes filters that meant a typo'd
3024        // constraint (`require_modalities: ["audoi"]`) was
3025        // re-interpreted as "require Text" and returned the
3026        // wrong nodes. Now `None`; callers must handle the
3027        // unknown case explicitly.
3028        _ => None,
3029    }
3030}
3031
3032fn parse_accelerator_type_cap(s: &str) -> AcceleratorType {
3033    match s.to_ascii_lowercase().as_str() {
3034        "tpu" => AcceleratorType::Tpu,
3035        "npu" => AcceleratorType::Npu,
3036        "fpga" => AcceleratorType::Fpga,
3037        "asic" => AcceleratorType::Asic,
3038        "dsp" => AcceleratorType::Dsp,
3039        _ => AcceleratorType::Unknown,
3040    }
3041}
3042
3043// ----- JSON shapes -----------------------------------------------------------
3044
3045#[derive(Deserialize, Default)]
3046struct CapabilitySetJson {
3047    #[serde(default)]
3048    hardware: Option<HardwareJson>,
3049    #[serde(default)]
3050    software: Option<SoftwareJson>,
3051    #[serde(default)]
3052    models: Vec<ModelJson>,
3053    #[serde(default)]
3054    tools: Vec<ToolJson>,
3055    #[serde(default)]
3056    tags: Vec<String>,
3057    #[serde(default)]
3058    limits: Option<LimitsJson>,
3059}
3060
3061#[derive(Deserialize, Default)]
3062struct HardwareJson {
3063    cpu_cores: Option<u32>,
3064    cpu_threads: Option<u32>,
3065    memory_gb: Option<u32>,
3066    gpu: Option<GpuJson>,
3067    #[serde(default)]
3068    additional_gpus: Vec<GpuJson>,
3069    storage_gb: Option<u64>,
3070    network_gbps: Option<u32>,
3071    #[serde(default)]
3072    accelerators: Vec<AcceleratorJson>,
3073}
3074
3075#[derive(Deserialize)]
3076struct GpuJson {
3077    vendor: Option<String>,
3078    #[serde(default)]
3079    model: String,
3080    #[serde(default)]
3081    vram_gb: u32,
3082    compute_units: Option<u32>,
3083    tensor_cores: Option<u32>,
3084    fp16_tflops_x10: Option<u32>,
3085}
3086
3087#[derive(Deserialize)]
3088struct AcceleratorJson {
3089    #[serde(default)]
3090    kind: String,
3091    #[serde(default)]
3092    model: String,
3093    memory_gb: Option<u32>,
3094    tops_x10: Option<u32>,
3095}
3096
3097#[derive(Deserialize, Default)]
3098struct SoftwareJson {
3099    os: Option<String>,
3100    os_version: Option<String>,
3101    #[serde(default)]
3102    runtimes: Vec<Vec<String>>,
3103    #[serde(default)]
3104    frameworks: Vec<Vec<String>>,
3105    cuda_version: Option<String>,
3106    #[serde(default)]
3107    drivers: Vec<Vec<String>>,
3108}
3109
3110#[derive(Deserialize)]
3111struct ModelJson {
3112    #[serde(default)]
3113    model_id: String,
3114    #[serde(default)]
3115    family: String,
3116    parameters_b_x10: Option<u32>,
3117    context_length: Option<u32>,
3118    quantization: Option<String>,
3119    #[serde(default)]
3120    modalities: Vec<String>,
3121    tokens_per_sec: Option<u32>,
3122    loaded: Option<bool>,
3123}
3124
3125#[derive(Deserialize)]
3126struct ToolJson {
3127    #[serde(default)]
3128    tool_id: String,
3129    #[serde(default)]
3130    name: String,
3131    version: Option<String>,
3132    input_schema: Option<String>,
3133    output_schema: Option<String>,
3134    #[serde(default)]
3135    requires: Vec<String>,
3136    estimated_time_ms: Option<u32>,
3137    stateless: Option<bool>,
3138}
3139
3140#[derive(Deserialize, Default)]
3141struct LimitsJson {
3142    max_concurrent_requests: Option<u32>,
3143    max_tokens_per_request: Option<u32>,
3144    rate_limit_rpm: Option<u32>,
3145    max_batch_size: Option<u32>,
3146    max_input_bytes: Option<u32>,
3147    max_output_bytes: Option<u32>,
3148}
3149
3150#[derive(Deserialize, Default)]
3151struct CapabilityFilterJson {
3152    #[serde(default)]
3153    require_tags: Vec<String>,
3154    #[serde(default)]
3155    require_models: Vec<String>,
3156    #[serde(default)]
3157    require_tools: Vec<String>,
3158    min_memory_gb: Option<u32>,
3159    require_gpu: Option<bool>,
3160    gpu_vendor: Option<String>,
3161    min_vram_gb: Option<u32>,
3162    min_context_length: Option<u32>,
3163    #[serde(default)]
3164    require_modalities: Vec<String>,
3165}
3166
3167// ----- Conversions -----------------------------------------------------------
3168
3169fn pair_vec(xs: Vec<Vec<String>>) -> Vec<(String, String)> {
3170    xs.into_iter()
3171        .filter_map(|mut p| {
3172            if p.len() >= 2 {
3173                Some((std::mem::take(&mut p[0]), std::mem::take(&mut p[1])))
3174            } else {
3175                None
3176            }
3177        })
3178        .collect()
3179}
3180
3181/// Clamp an untrusted JSON `u32` into a core `u16` field,
3182/// saturating at `u16::MAX`. Bare `as u16` silently wraps on
3183/// overflow — a Go caller reporting 65536 cores could land 0 on
3184/// the wire. Applied uniformly so every capability JSON
3185/// conversion is consistent with the NAPI + PyO3 paths.
3186#[inline]
3187fn saturating_u16_cap(v: u32) -> u16 {
3188    v.min(u16::MAX as u32) as u16
3189}
3190
3191fn gpu_info_from_json(g: GpuJson) -> GpuInfo {
3192    let vendor = g
3193        .vendor
3194        .as_deref()
3195        .map(parse_gpu_vendor_cap)
3196        .unwrap_or(GpuVendor::Unknown);
3197    let mut info = GpuInfo::new(vendor, g.model, g.vram_gb);
3198    if let Some(cu) = g.compute_units {
3199        info = info.with_compute_units(saturating_u16_cap(cu));
3200    }
3201    if let Some(tc) = g.tensor_cores {
3202        info = info.with_tensor_cores(saturating_u16_cap(tc));
3203    }
3204    if let Some(tf) = g.fp16_tflops_x10 {
3205        // Saturate at `u16::MAX` before the f32 conversion. Pre-fix
3206        // `tf as f32` lost precision for u32 values ≥ 2²⁴ (f32 has
3207        // a 24-bit mantissa), so the round-trip
3208        // `u32 → f32/10.0 → with_fp16_tflops → *10.0 as u32`
3209        // could land a different `fp16_tflops_x10` than the
3210        // operator declared. The neighboring `tops_x10` field
3211        // already routes through `saturating_u16_cap` for the same
3212        // reason; the matching cap here keeps the round-trip exact
3213        // (u16::MAX = 65 535 is far below the f32 precision
3214        // boundary of 2²⁴ = 16 777 216) and aligns the two fields'
3215        // surfaces. The dynamic range loss (2³² → 2¹⁶) is
3216        // acceptable: 6 553.5 TFLOPS is far above any current or
3217        // near-future GPU's fp16 throughput.
3218        let tf_capped = saturating_u16_cap(tf);
3219        info = info.with_fp16_tflops(tf_capped as f32 / 10.0);
3220    }
3221    info
3222}
3223
3224fn accelerator_from_json(a: AcceleratorJson) -> AcceleratorInfo {
3225    AcceleratorInfo {
3226        accel_type: parse_accelerator_type_cap(&a.kind),
3227        model: a.model,
3228        memory_gb: a.memory_gb.unwrap_or(0),
3229        tops_x10: a.tops_x10.map(saturating_u16_cap).unwrap_or(0),
3230    }
3231}
3232
3233fn hardware_from_json(h: HardwareJson) -> HardwareCapabilities {
3234    let mut hw = HardwareCapabilities::new();
3235    match (h.cpu_cores, h.cpu_threads) {
3236        (Some(c), Some(t)) => hw = hw.with_cpu(saturating_u16_cap(c), saturating_u16_cap(t)),
3237        (Some(c), None) => {
3238            let c16 = saturating_u16_cap(c);
3239            hw = hw.with_cpu(c16, c16);
3240        }
3241        _ => {}
3242    }
3243    if let Some(mb) = h.memory_gb {
3244        hw = hw.with_memory(mb);
3245    }
3246    if let Some(g) = h.gpu {
3247        hw = hw.with_gpu(gpu_info_from_json(g));
3248    }
3249    for g in h.additional_gpus {
3250        hw = hw.add_gpu(gpu_info_from_json(g));
3251    }
3252    if let Some(mb) = h.storage_gb {
3253        hw = hw.with_storage(mb);
3254    }
3255    if let Some(gbps) = h.network_gbps {
3256        hw = hw.with_network(gbps);
3257    }
3258    for a in h.accelerators {
3259        hw = hw.add_accelerator(accelerator_from_json(a));
3260    }
3261    hw
3262}
3263
3264fn software_from_json(s: SoftwareJson) -> SoftwareCapabilities {
3265    let mut sw = SoftwareCapabilities::new()
3266        .with_os(s.os.unwrap_or_default(), s.os_version.unwrap_or_default());
3267    for (k, v) in pair_vec(s.runtimes) {
3268        sw = sw.add_runtime(k, v);
3269    }
3270    for (k, v) in pair_vec(s.frameworks) {
3271        sw = sw.add_framework(k, v);
3272    }
3273    if let Some(c) = s.cuda_version {
3274        sw = sw.with_cuda(c);
3275    }
3276    sw.drivers = pair_vec(s.drivers);
3277    sw
3278}
3279
3280fn model_from_json(m: ModelJson) -> ModelCapability {
3281    let mut mc = ModelCapability::new(m.model_id, m.family);
3282    if let Some(p) = m.parameters_b_x10 {
3283        mc.parameters_b_x10 = p;
3284    }
3285    if let Some(c) = m.context_length {
3286        mc = mc.with_context_length(c);
3287    }
3288    if let Some(q) = m.quantization {
3289        mc = mc.with_quantization(q);
3290    }
3291    for modality in m.modalities {
3292        match parse_modality_cap(&modality) {
3293            Some(parsed) => mc = mc.add_modality(parsed),
3294            None => {
3295                tracing::warn!(
3296                    modality = %modality,
3297                    "announce_capabilities: unknown modality string (typo?), \
3298                     skipping rather than the pre-fix silent fallback to Text — \
3299                     advertising a Text capability the node doesn't actually \
3300                     have produced wrong scheduling decisions on the receiver",
3301                );
3302            }
3303        }
3304    }
3305    if let Some(t) = m.tokens_per_sec {
3306        mc = mc.with_tokens_per_sec(t);
3307    }
3308    if let Some(l) = m.loaded {
3309        mc = mc.with_loaded(l);
3310    }
3311    mc
3312}
3313
3314fn tool_from_json(t: ToolJson) -> ToolCapability {
3315    let mut tc = ToolCapability::new(t.tool_id, t.name);
3316    if let Some(v) = t.version {
3317        tc = tc.with_version(v);
3318    }
3319    if let Some(s) = t.input_schema {
3320        tc = tc.with_input_schema(s);
3321    }
3322    if let Some(s) = t.output_schema {
3323        tc = tc.with_output_schema(s);
3324    }
3325    for r in t.requires {
3326        tc = tc.requires(r);
3327    }
3328    if let Some(ms) = t.estimated_time_ms {
3329        tc = tc.with_estimated_time(ms);
3330    }
3331    if let Some(st) = t.stateless {
3332        tc = tc.with_stateless(st);
3333    }
3334    tc
3335}
3336
3337fn limits_from_json(l: LimitsJson) -> ResourceLimits {
3338    let mut rl = ResourceLimits::new();
3339    if let Some(n) = l.max_concurrent_requests {
3340        rl = rl.with_max_concurrent(n);
3341    }
3342    if let Some(n) = l.max_tokens_per_request {
3343        rl = rl.with_max_tokens(n);
3344    }
3345    if let Some(n) = l.rate_limit_rpm {
3346        rl = rl.with_rate_limit(n);
3347    }
3348    if let Some(n) = l.max_batch_size {
3349        rl = rl.with_max_batch(n);
3350    }
3351    if let Some(n) = l.max_input_bytes {
3352        rl.max_input_bytes = n;
3353    }
3354    if let Some(n) = l.max_output_bytes {
3355        rl.max_output_bytes = n;
3356    }
3357    rl
3358}
3359
3360fn capability_set_from_json(caps: CapabilitySetJson) -> CapabilitySet {
3361    let mut cs = CapabilitySet::new();
3362    if let Some(h) = caps.hardware {
3363        cs = cs.with_hardware(hardware_from_json(h));
3364    }
3365    if let Some(s) = caps.software {
3366        cs = cs.with_software(software_from_json(s));
3367    }
3368    for m in caps.models {
3369        cs = cs.add_model(model_from_json(m));
3370    }
3371    for t in caps.tools {
3372        cs = cs.add_tool(tool_from_json(t));
3373    }
3374    // Reserved-prefix scope tags can't go through `add_tag` — it
3375    // uses `Tag::parse_user` which rejects reserved prefixes and
3376    // silently drops them, leaving the announcement with no scope
3377    // and resolving to `CapabilityScope::Global` (visible to every
3378    // tenant / region query). Route the three scope shapes to the
3379    // typed helpers so wire-form `scope:*` strings from bindings
3380    // land as `Tag::Reserved` entries the scope resolver sees.
3381    for tag in caps.tags {
3382        if tag == TAG_SCOPE_SUBNET_LOCAL {
3383            cs = cs.with_subnet_local_scope();
3384        } else if let Some(id) = tag.strip_prefix(TAG_SCOPE_TENANT_PREFIX) {
3385            cs = cs.with_tenant_scope(id);
3386        } else if let Some(name) = tag.strip_prefix(TAG_SCOPE_REGION_PREFIX) {
3387            cs = cs.with_region_scope(name);
3388        } else {
3389            cs = cs.add_tag(tag);
3390        }
3391    }
3392    if let Some(l) = caps.limits {
3393        cs = cs.with_limits(limits_from_json(l));
3394    }
3395    cs
3396}
3397
3398fn capability_filter_from_json(f: CapabilityFilterJson) -> CapabilityFilter {
3399    let mut cf = CapabilityFilter::new();
3400    for t in f.require_tags {
3401        cf = cf.require_tag(t);
3402    }
3403    for m in f.require_models {
3404        cf = cf.require_model(m);
3405    }
3406    for t in f.require_tools {
3407        cf = cf.require_tool(t);
3408    }
3409    if let Some(mb) = f.min_memory_gb {
3410        cf = cf.with_min_memory(mb);
3411    }
3412    if f.require_gpu.unwrap_or(false) {
3413        cf = cf.require_gpu();
3414    }
3415    if let Some(v) = f.gpu_vendor {
3416        cf = cf.with_gpu_vendor(parse_gpu_vendor_cap(&v));
3417    }
3418    if let Some(mb) = f.min_vram_gb {
3419        cf = cf.with_min_vram(mb);
3420    }
3421    if let Some(n) = f.min_context_length {
3422        cf = cf.with_min_context(n);
3423    }
3424    for m in f.require_modalities {
3425        match parse_modality_cap(&m) {
3426            Some(parsed) => cf = cf.require_modality(parsed),
3427            None => {
3428                // For a filter, the lossy direction matters even
3429                // more than for announce: pre-fix the typo'd
3430                // string was re-interpreted as `require Text`,
3431                // returning Text-capable nodes that did NOT
3432                // satisfy the operator's intended constraint.
3433                // Skipping the unknown is also imperfect (the
3434                // resulting filter is too permissive — it
3435                // returns more nodes than intended), but the
3436                // failure mode is "scheduler matched too
3437                // broadly" rather than "scheduler matched the
3438                // wrong type." The loud warn surfaces the typo
3439                // so operators can fix it.
3440                tracing::warn!(
3441                    modality = %m,
3442                    "find_nodes: unknown modality string in require_modalities \
3443                     filter (typo?), dropping the constraint; the resulting \
3444                     filter is too permissive — pre-fix it was silently \
3445                     re-interpreted as `require Text`, which returned the \
3446                     wrong nodes",
3447                );
3448            }
3449        }
3450    }
3451    cf
3452}
3453
3454// ----- Exports ---------------------------------------------------------------
3455
3456pub(crate) const NET_ERR_CAPABILITY: c_int = -128;
3457
3458/// Announce this node's capabilities to every directly-connected
3459/// peer. Also self-indexes, so `find_nodes` on the same node matches
3460/// on the announcement. Multi-hop propagation is deferred.
3461///
3462/// `caps_json` is the same POJO shape as PyO3 / NAPI:
3463/// `{hardware, software, models, tools, tags, limits}`.
3464#[unsafe(no_mangle)]
3465pub unsafe extern "C" fn net_mesh_announce_capabilities(
3466    handle: *mut MeshNodeHandle,
3467    caps_json: *const c_char,
3468) -> c_int {
3469    if handle.is_null() || caps_json.is_null() {
3470        return NetError::NullPointer.into();
3471    }
3472    let h = unsafe { &*handle };
3473    let _op = match h.guard.try_enter() {
3474        Some(op) => op,
3475        None => return NetError::ShuttingDown.into(),
3476    };
3477    let Some(s) = (unsafe { c_str_to_string(caps_json) }) else {
3478        return NetError::InvalidUtf8.into();
3479    };
3480    let parsed: CapabilitySetJson = match serde_json::from_str(&s) {
3481        Ok(v) => v,
3482        Err(_) => return NetError::InvalidJson.into(),
3483    };
3484    let caps = capability_set_from_json(parsed);
3485    let node = h.inner.clone();
3486    match block_on(async move { node.announce_capabilities(caps).await }) {
3487        Ok(()) => 0,
3488        Err(_) => NET_ERR_CAPABILITY,
3489    }
3490}
3491
3492/// Query the local capability index. Writes a JSON array of node
3493/// ids (u64) to `*out_json`; caller frees via `net_free_string`.
3494#[unsafe(no_mangle)]
3495pub unsafe extern "C" fn net_mesh_find_nodes(
3496    handle: *mut MeshNodeHandle,
3497    filter_json: *const c_char,
3498    out_json: *mut *mut c_char,
3499    out_len: *mut usize,
3500) -> c_int {
3501    if handle.is_null() || filter_json.is_null() || out_json.is_null() || out_len.is_null() {
3502        return NetError::NullPointer.into();
3503    }
3504    let h = unsafe { &*handle };
3505    let _op = match h.guard.try_enter() {
3506        Some(op) => op,
3507        None => return NetError::ShuttingDown.into(),
3508    };
3509    let Some(s) = (unsafe { c_str_to_string(filter_json) }) else {
3510        return NetError::InvalidUtf8.into();
3511    };
3512    let parsed: CapabilityFilterJson = match serde_json::from_str(&s) {
3513        Ok(v) => v,
3514        Err(_) => return NetError::InvalidJson.into(),
3515    };
3516    let filter = capability_filter_from_json(parsed);
3517    let ids = h.inner.find_nodes_by_filter(&filter);
3518    write_json_out(&ids, out_json, out_len)
3519}
3520
3521/// JSON shape of a [`ScopeFilter`] for the C ABI. Mirrors the
3522/// NAPI / PyO3 tagged-union form:
3523///
3524/// ```text
3525/// {"kind": "any"}
3526/// {"kind": "global_only"}
3527/// {"kind": "same_subnet"}
3528/// {"kind": "tenant", "tenant": "<id>"}
3529/// {"kind": "tenants", "tenants": ["<id>", ...]}
3530/// {"kind": "region", "region": "<name>"}
3531/// {"kind": "regions", "regions": ["<name>", ...]}
3532/// ```
3533///
3534/// An unrecognized `kind`, a missing/empty required selector, or a list
3535/// that is empty once empty entries are removed is REJECTED with
3536/// [`NetError::InvalidArgument`] — see [`scope_filter_from_json`] for
3537/// why widening to `Any` was the wrong default. Matches the PyO3 / NAPI
3538/// converters.
3539#[derive(serde::Deserialize)]
3540struct ScopeFilterJson {
3541    kind: String,
3542    #[serde(default)]
3543    tenant: Option<String>,
3544    #[serde(default)]
3545    tenants: Option<Vec<String>>,
3546    #[serde(default)]
3547    region: Option<String>,
3548    #[serde(default)]
3549    regions: Option<Vec<String>>,
3550}
3551
3552/// Owned scope filter holding the strings the borrowed
3553/// [`net::adapter::net::behavior::capability::ScopeFilter`] points
3554/// into. Constructed inside [`net_mesh_find_nodes_scoped`] and
3555/// dropped at the end of the call so the borrow stays valid for
3556/// the query.
3557enum ScopeFilterOwned {
3558    Any,
3559    GlobalOnly,
3560    SameSubnet,
3561    Tenant(String),
3562    Tenants(Vec<String>),
3563    Region(String),
3564    Regions(Vec<String>),
3565}
3566
3567/// Convert the deserialized scope-filter object into the owned form.
3568///
3569/// Returns `Err(NetError::InvalidArgument)` for an object that parsed as
3570/// JSON but carries no usable selector: an unrecognized `kind`, a
3571/// missing/empty required selector, or a list that is empty once empty
3572/// entries are removed.
3573///
3574/// These three shapes all used to collapse to [`ScopeFilterOwned::Any`],
3575/// on the reasoning that an empty tenant id could never match a real
3576/// tenant tag. But `Any` is the BROADEST filter — every non-`SubnetLocal`
3577/// peer in the mesh — so a caller whose tenant id came through empty
3578/// silently queried everything and selected a provider from it. A
3579/// narrowing filter that cannot narrow must fail, not widen.
3580///
3581/// `GlobalOnly` is deliberately not used as the fallback either: it
3582/// would still return (and let the caller select from) every unscoped
3583/// provider. The caller asked to narrow by an identity it did not
3584/// supply; the honest answers are an error or no matches.
3585fn scope_filter_from_json(f: ScopeFilterJson) -> Result<ScopeFilterOwned, NetError> {
3586    // Drop empty entries, then require at least one survivor —
3587    // `scope_from_membership_tags` never produces an empty tenant/region,
3588    // so an all-empty list can only be caller error.
3589    fn clean(v: Vec<String>) -> Option<Vec<String>> {
3590        let cleaned: Vec<String> = v.into_iter().filter(|s| !s.is_empty()).collect();
3591        (!cleaned.is_empty()).then_some(cleaned)
3592    }
3593    let filter = match f.kind.as_str() {
3594        "any" => ScopeFilterOwned::Any,
3595        "global_only" | "globalOnly" => ScopeFilterOwned::GlobalOnly,
3596        "same_subnet" | "sameSubnet" => ScopeFilterOwned::SameSubnet,
3597        "tenant" => match f.tenant {
3598            Some(t) if !t.is_empty() => ScopeFilterOwned::Tenant(t),
3599            _ => return Err(NetError::InvalidArgument),
3600        },
3601        "tenants" => match f.tenants.and_then(clean) {
3602            Some(ts) => ScopeFilterOwned::Tenants(ts),
3603            None => return Err(NetError::InvalidArgument),
3604        },
3605        "region" => match f.region {
3606            Some(r) if !r.is_empty() => ScopeFilterOwned::Region(r),
3607            _ => return Err(NetError::InvalidArgument),
3608        },
3609        "regions" => match f.regions.and_then(clean) {
3610            Some(rs) => ScopeFilterOwned::Regions(rs),
3611            None => return Err(NetError::InvalidArgument),
3612        },
3613        _ => return Err(NetError::InvalidArgument),
3614    };
3615    Ok(filter)
3616}
3617
3618/// Run `f` with a borrowed scope filter projected from `owned`.
3619/// Multi-element variants need an intermediate `Vec<&str>` that
3620/// outlives the borrow — that intermediate lives on this call's
3621/// stack, matching the NAPI / PyO3 helpers.
3622fn with_scope_filter<R>(
3623    owned: &ScopeFilterOwned,
3624    f: impl FnOnce(&crate::adapter::net::behavior::capability::ScopeFilter<'_>) -> R,
3625) -> R {
3626    use crate::adapter::net::behavior::capability::ScopeFilter as F;
3627    match owned {
3628        ScopeFilterOwned::Any => f(&F::Any),
3629        ScopeFilterOwned::GlobalOnly => f(&F::GlobalOnly),
3630        ScopeFilterOwned::SameSubnet => f(&F::SameSubnet),
3631        ScopeFilterOwned::Tenant(t) => f(&F::Tenant(t.as_str())),
3632        ScopeFilterOwned::Tenants(ts) => {
3633            let refs: Vec<&str> = ts.iter().map(|s| s.as_str()).collect();
3634            f(&F::Tenants(refs.as_slice()))
3635        }
3636        ScopeFilterOwned::Region(r) => f(&F::Region(r.as_str())),
3637        ScopeFilterOwned::Regions(rs) => {
3638            let refs: Vec<&str> = rs.iter().map(|s| s.as_str()).collect();
3639            f(&F::Regions(refs.as_slice()))
3640        }
3641    }
3642}
3643
3644/// Scoped variant of [`net_mesh_find_nodes`]. Filters candidates
3645/// through a scope filter derived from each node's `scope:*`
3646/// reserved tags. Untagged nodes resolve to `Global` and stay
3647/// visible under most filters; nodes tagged `scope:subnet-local`
3648/// only show up under `{"kind":"same_subnet"}`.
3649///
3650/// `scope_json` is a tagged-union JSON form (see the private
3651/// `ScopeFilterJson` struct above):
3652///
3653/// ```text
3654/// {"kind": "any"}
3655/// {"kind": "global_only"}
3656/// {"kind": "same_subnet"}
3657/// {"kind": "tenant", "tenant": "<id>"}
3658/// {"kind": "tenants", "tenants": ["<id>", ...]}
3659/// {"kind": "region", "region": "<name>"}
3660/// {"kind": "regions", "regions": ["<name>", ...]}
3661/// ```
3662///
3663/// `filter_json` is the same shape as [`net_mesh_find_nodes`].
3664/// Result: JSON array of u64 node ids written to `*out_json`;
3665/// caller frees via `net_free_string`.
3666#[unsafe(no_mangle)]
3667pub unsafe extern "C" fn net_mesh_find_nodes_scoped(
3668    handle: *mut MeshNodeHandle,
3669    filter_json: *const c_char,
3670    scope_json: *const c_char,
3671    out_json: *mut *mut c_char,
3672    out_len: *mut usize,
3673) -> c_int {
3674    if handle.is_null()
3675        || filter_json.is_null()
3676        || scope_json.is_null()
3677        || out_json.is_null()
3678        || out_len.is_null()
3679    {
3680        return NetError::NullPointer.into();
3681    }
3682    let h = unsafe { &*handle };
3683    let _op = match h.guard.try_enter() {
3684        Some(op) => op,
3685        None => return NetError::ShuttingDown.into(),
3686    };
3687    let Some(filter_s) = (unsafe { c_str_to_string(filter_json) }) else {
3688        return NetError::InvalidUtf8.into();
3689    };
3690    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
3691        return NetError::InvalidUtf8.into();
3692    };
3693    let parsed_filter: CapabilityFilterJson = match serde_json::from_str(&filter_s) {
3694        Ok(v) => v,
3695        Err(_) => return NetError::InvalidJson.into(),
3696    };
3697    let parsed_scope: ScopeFilterJson = match serde_json::from_str(&scope_s) {
3698        Ok(v) => v,
3699        Err(_) => return NetError::InvalidJson.into(),
3700    };
3701    let filter = capability_filter_from_json(parsed_filter);
3702    let owned = match scope_filter_from_json(parsed_scope) {
3703        Ok(v) => v,
3704        Err(e) => return e.into(),
3705    };
3706    let ids = with_scope_filter(&owned, |sf| {
3707        h.inner.find_nodes_by_filter_scoped(&filter, sf)
3708    });
3709    write_json_out(&ids, out_json, out_len)
3710}
3711
3712/// JSON shape of [`CapabilityRequirement`] for the C ABI. Mirrors
3713/// the field set of the core type with snake_case keys; weights are
3714/// f32 in [0.0, 1.0] (the core clamps).
3715///
3716/// ```text
3717/// {
3718///   "filter": { … CapabilityFilter shape … },
3719///   "prefer_more_memory":     0.5,
3720///   "prefer_more_vram":       1.0,
3721///   "prefer_faster_inference": 0.0,
3722///   "prefer_loaded_models":   0.0
3723/// }
3724/// ```
3725#[derive(serde::Deserialize)]
3726struct CapabilityRequirementJson {
3727    #[serde(default)]
3728    filter: CapabilityFilterJson,
3729    #[serde(default)]
3730    prefer_more_memory: f32,
3731    #[serde(default)]
3732    prefer_more_vram: f32,
3733    #[serde(default)]
3734    prefer_faster_inference: f32,
3735    #[serde(default)]
3736    prefer_loaded_models: f32,
3737}
3738
3739fn capability_requirement_from_json(
3740    j: CapabilityRequirementJson,
3741) -> crate::adapter::net::behavior::capability::CapabilityRequirement {
3742    crate::adapter::net::behavior::capability::CapabilityRequirement::from_filter(
3743        capability_filter_from_json(j.filter),
3744    )
3745    .prefer_memory(j.prefer_more_memory)
3746    .prefer_vram(j.prefer_more_vram)
3747    .prefer_speed(j.prefer_faster_inference)
3748    .prefer_loaded(j.prefer_loaded_models)
3749}
3750
3751/// Pick the best-scoring node for a placement requirement. Writes
3752/// the winning node id to `*out_node_id` and `1` to `*out_has_match`
3753/// when a node matches; writes `0` to `*out_has_match` and leaves
3754/// `*out_node_id` untouched when no node matches. Returns `0` for
3755/// success in either case; non-zero only on input / parse error.
3756///
3757/// `requirement_json` is the JSON form documented on the private
3758/// `CapabilityRequirementJson` struct above — a `filter` object
3759/// plus four optional `prefer_*` weights in `[0.0, 1.0]`.
3760#[unsafe(no_mangle)]
3761pub unsafe extern "C" fn net_mesh_find_best_node(
3762    handle: *mut MeshNodeHandle,
3763    requirement_json: *const c_char,
3764    out_node_id: *mut u64,
3765    out_has_match: *mut c_int,
3766) -> c_int {
3767    if handle.is_null()
3768        || requirement_json.is_null()
3769        || out_node_id.is_null()
3770        || out_has_match.is_null()
3771    {
3772        return NetError::NullPointer.into();
3773    }
3774    let h = unsafe { &*handle };
3775    let _op = match h.guard.try_enter() {
3776        Some(op) => op,
3777        None => return NetError::ShuttingDown.into(),
3778    };
3779    let Some(s) = (unsafe { c_str_to_string(requirement_json) }) else {
3780        return NetError::InvalidUtf8.into();
3781    };
3782    let parsed: CapabilityRequirementJson = match serde_json::from_str(&s) {
3783        Ok(v) => v,
3784        Err(_) => return NetError::InvalidJson.into(),
3785    };
3786    let req = capability_requirement_from_json(parsed);
3787    match h.inner.find_best_node(&req) {
3788        Some(node_id) => unsafe {
3789            *out_node_id = node_id;
3790            *out_has_match = 1;
3791        },
3792        None => unsafe {
3793            *out_has_match = 0;
3794        },
3795    }
3796    0
3797}
3798
3799/// Scoped variant of [`net_mesh_find_best_node`]. Filters
3800/// candidates through `scope_json` (same shape as
3801/// [`net_mesh_find_nodes_scoped`]) before scoring; picks the
3802/// highest-scoring node within the scope-filtered set.
3803///
3804/// Same out-param contract as [`net_mesh_find_best_node`]:
3805/// `*out_has_match = 1` + `*out_node_id = winner` on hit;
3806/// `*out_has_match = 0` on no match.
3807#[unsafe(no_mangle)]
3808pub unsafe extern "C" fn net_mesh_find_best_node_scoped(
3809    handle: *mut MeshNodeHandle,
3810    requirement_json: *const c_char,
3811    scope_json: *const c_char,
3812    out_node_id: *mut u64,
3813    out_has_match: *mut c_int,
3814) -> c_int {
3815    if handle.is_null()
3816        || requirement_json.is_null()
3817        || scope_json.is_null()
3818        || out_node_id.is_null()
3819        || out_has_match.is_null()
3820    {
3821        return NetError::NullPointer.into();
3822    }
3823    let h = unsafe { &*handle };
3824    let _op = match h.guard.try_enter() {
3825        Some(op) => op,
3826        None => return NetError::ShuttingDown.into(),
3827    };
3828    let Some(req_s) = (unsafe { c_str_to_string(requirement_json) }) else {
3829        return NetError::InvalidUtf8.into();
3830    };
3831    let Some(scope_s) = (unsafe { c_str_to_string(scope_json) }) else {
3832        return NetError::InvalidUtf8.into();
3833    };
3834    let parsed_req: CapabilityRequirementJson = match serde_json::from_str(&req_s) {
3835        Ok(v) => v,
3836        Err(_) => return NetError::InvalidJson.into(),
3837    };
3838    let parsed_scope: ScopeFilterJson = match serde_json::from_str(&scope_s) {
3839        Ok(v) => v,
3840        Err(_) => return NetError::InvalidJson.into(),
3841    };
3842    let req = capability_requirement_from_json(parsed_req);
3843    let owned = match scope_filter_from_json(parsed_scope) {
3844        Ok(v) => v,
3845        Err(e) => return e.into(),
3846    };
3847    let result = with_scope_filter(&owned, |sf| h.inner.find_best_node_scoped(&req, sf));
3848    match result {
3849        Some(node_id) => unsafe {
3850            *out_node_id = node_id;
3851            *out_has_match = 1;
3852        },
3853        None => unsafe {
3854            *out_has_match = 0;
3855        },
3856    }
3857    0
3858}
3859
3860/// Normalize a GPU vendor string to its canonical lowercase form.
3861#[unsafe(no_mangle)]
3862pub unsafe extern "C" fn net_normalize_gpu_vendor(
3863    raw: *const c_char,
3864    out_json: *mut *mut c_char,
3865    out_len: *mut usize,
3866) -> c_int {
3867    if raw.is_null() || out_json.is_null() || out_len.is_null() {
3868        return NetError::NullPointer.into();
3869    }
3870    let Some(s) = (unsafe { c_str_to_string(raw) }) else {
3871        return NetError::InvalidUtf8.into();
3872    };
3873    let canonical = gpu_vendor_to_string_cap(parse_gpu_vendor_cap(&s));
3874    write_string_out(canonical.to_string(), out_json, out_len)
3875}
3876
3877// =========================================================================
3878// Gang-claim GPU-island scheduler — C ABI shipped in `net.h`
3879// =========================================================================
3880//
3881// Node-level surface (D3: reuse the existing `MeshNodeHandle`). Match
3882// criteria and the island record cross the boundary as small JSON
3883// strings (the codebase's `_json` convention), so the C ABI stays one
3884// `const char*` instead of a struct + string-array marshaling.
3885
3886/// Returned for a bad / unparseable criteria or record JSON.
3887pub(crate) const NET_ERR_GANG_INVALID: c_int = -140;
3888
3889/// Flat match criteria (parsed from the `criteria_json` argument). Built
3890/// into the core `MatchCriteria` so callers never touch the internal
3891/// `CapabilityQuery` / policy enum shapes.
3892#[derive(Deserialize)]
3893struct GangCriteriaJson {
3894    // Host capability match (step 1) — mirrors `CapabilityFilter`.
3895    #[serde(default)]
3896    tags_all: Vec<String>,
3897    #[serde(default)]
3898    tags_any: Vec<String>,
3899    #[serde(default)]
3900    tag_groups_all: Vec<Vec<String>>,
3901    // Host network-locality (subnet / zone / availability region).
3902    #[serde(default)]
3903    region: Option<String>,
3904    // Live island numeric filter (step 2).
3905    #[serde(default)]
3906    min_units: usize,
3907    #[serde(default)]
3908    max_load: Option<f32>,
3909    #[serde(default)]
3910    max_p50_latency_us: Option<u32>,
3911    #[serde(default)]
3912    require_all: Vec<String>,
3913    #[serde(default)]
3914    require_any: Vec<String>,
3915    #[serde(default)]
3916    selection: Option<String>,
3917    #[serde(default)]
3918    load_band_target: Option<f32>,
3919    #[serde(default)]
3920    prefer_capability: Option<String>,
3921}
3922
3923/// One island a node self-publishes (parsed from `record_json`). Its
3924/// `host` is forced to this node.
3925#[derive(Deserialize)]
3926struct IslandRecordJson {
3927    id: u64,
3928    #[serde(default)]
3929    units: Vec<u32>,
3930    #[serde(default)]
3931    capabilities: Vec<String>,
3932    #[serde(default)]
3933    load: f32,
3934    #[serde(default)]
3935    p50_latency_us: u32,
3936}
3937
3938fn build_gang_criteria(
3939    c: GangCriteriaJson,
3940) -> Option<crate::adapter::net::behavior::gang::MatchCriteria> {
3941    use crate::adapter::net::behavior::fold::{CapabilityFilter, CapabilityQuery};
3942    use crate::adapter::net::behavior::gang::{MatchCriteria, NumericFilter, SelectionPolicy};
3943    let selection = match c.selection.as_deref() {
3944        None | Some("least_loaded") => SelectionPolicy::LeastLoaded,
3945        Some("pack") => SelectionPolicy::Pack,
3946        Some("lowest_id") => SelectionPolicy::LowestId,
3947        Some("load_band") => SelectionPolicy::LoadBand(c.load_band_target.unwrap_or(0.5)),
3948        Some(_) => return None,
3949    };
3950    Some(MatchCriteria {
3951        capability: CapabilityQuery::Composite(CapabilityFilter {
3952            tags_all: c.tags_all,
3953            tags_any: c.tags_any,
3954            tag_groups_all: c.tag_groups_all,
3955            region: c.region,
3956            ..Default::default()
3957        }),
3958        numeric: NumericFilter {
3959            min_units: c.min_units,
3960            max_load: c.max_load,
3961            max_p50_latency_us: c.max_p50_latency_us,
3962            require_all: c.require_all,
3963            require_any: c.require_any,
3964        },
3965        selection,
3966        prefer_capability: c.prefer_capability,
3967    })
3968}
3969
3970/// Publish this node's island-topology record (host forced to self).
3971/// `record_json` is `{"id":..,"units":[..],"capabilities":[..],"load":..,
3972/// "p50_latency_us":..}`. The peer fan-out count is written to
3973/// `*out_count` (may be NULL).
3974#[unsafe(no_mangle)]
3975pub unsafe extern "C" fn net_mesh_publish_island_topology(
3976    handle: *mut MeshNodeHandle,
3977    record_json: *const c_char,
3978    out_count: *mut usize,
3979) -> c_int {
3980    if handle.is_null() || record_json.is_null() {
3981        return NetError::NullPointer.into();
3982    }
3983    let h = unsafe { &*handle };
3984    let _op = match h.guard.try_enter() {
3985        Some(op) => op,
3986        None => return NetError::ShuttingDown.into(),
3987    };
3988    let Some(js) = (unsafe { c_str_to_string(record_json) }) else {
3989        return NetError::InvalidUtf8.into();
3990    };
3991    let rec: IslandRecordJson = match serde_json::from_str(&js) {
3992        Ok(r) => r,
3993        Err(_) => return NET_ERR_GANG_INVALID,
3994    };
3995    use crate::adapter::net::behavior::fold::{IslandRecord, UnitSet};
3996    let record = IslandRecord {
3997        id: rec.id,
3998        units: UnitSet::new(rec.units),
3999        host: 0, // forced to this node by publish
4000        capabilities: rec.capabilities,
4001        load: rec.load,
4002        p50_latency_us: rec.p50_latency_us,
4003    };
4004    let node = h.inner.clone();
4005    match block_on(async move { node.publish_island_topology(record).await }) {
4006        Ok(n) => {
4007            if !out_count.is_null() {
4008                unsafe {
4009                    *out_count = n;
4010                }
4011            }
4012            0
4013        }
4014        Err(e) => adapter_err_to_code(&e),
4015    }
4016}
4017
4018/// Match islands against `criteria_json` (read-only). Up to `cap`
4019/// island ids are written to `out_ids`; the total match count (which may
4020/// exceed `cap`) is written to `*out_count`.
4021#[unsafe(no_mangle)]
4022pub unsafe extern "C" fn net_mesh_match_islands(
4023    handle: *mut MeshNodeHandle,
4024    criteria_json: *const c_char,
4025    out_ids: *mut u64,
4026    cap: usize,
4027    out_count: *mut usize,
4028) -> c_int {
4029    if handle.is_null() || criteria_json.is_null() || out_count.is_null() {
4030        return NetError::NullPointer.into();
4031    }
4032    let h = unsafe { &*handle };
4033    let _op = match h.guard.try_enter() {
4034        Some(op) => op,
4035        None => return NetError::ShuttingDown.into(),
4036    };
4037    let Some(js) = (unsafe { c_str_to_string(criteria_json) }) else {
4038        return NetError::InvalidUtf8.into();
4039    };
4040    let parsed: GangCriteriaJson = match serde_json::from_str(&js) {
4041        Ok(c) => c,
4042        Err(_) => return NET_ERR_GANG_INVALID,
4043    };
4044    let Some(criteria) = build_gang_criteria(parsed) else {
4045        return NET_ERR_GANG_INVALID;
4046    };
4047    let ids = h.inner.match_islands(&criteria);
4048    unsafe {
4049        *out_count = ids.len();
4050        if !out_ids.is_null() {
4051            let n = ids.len().min(cap);
4052            std::ptr::copy_nonoverlapping(ids.as_ptr(), out_ids, n);
4053        }
4054    }
4055    0
4056}
4057
4058/// Reserve `island` until `until_unix_us`. On success writes `0` (won)
4059/// or `1` (lost) to `*out_outcome`.
4060#[unsafe(no_mangle)]
4061pub unsafe extern "C" fn net_mesh_reserve_island(
4062    handle: *mut MeshNodeHandle,
4063    island: u64,
4064    until_unix_us: u64,
4065    out_outcome: *mut c_int,
4066) -> c_int {
4067    if handle.is_null() || out_outcome.is_null() {
4068        return NetError::NullPointer.into();
4069    }
4070    let h = unsafe { &*handle };
4071    let _op = match h.guard.try_enter() {
4072        Some(op) => op,
4073        None => return NetError::ShuttingDown.into(),
4074    };
4075    let node = h.inner.clone();
4076    match block_on(async move { node.reserve_island(island, until_unix_us).await }) {
4077        Ok(outcome) => {
4078            unsafe {
4079                *out_outcome = claim_outcome_code(outcome);
4080            }
4081            0
4082        }
4083        Err(e) => adapter_err_to_code(&e),
4084    }
4085}
4086
4087/// Release `island` this node holds. On success writes `0` (won) or
4088/// `1` (lost — wasn't the holder) to `*out_outcome`.
4089#[unsafe(no_mangle)]
4090pub unsafe extern "C" fn net_mesh_release_island(
4091    handle: *mut MeshNodeHandle,
4092    island: u64,
4093    out_outcome: *mut c_int,
4094) -> c_int {
4095    if handle.is_null() || out_outcome.is_null() {
4096        return NetError::NullPointer.into();
4097    }
4098    let h = unsafe { &*handle };
4099    let _op = match h.guard.try_enter() {
4100        Some(op) => op,
4101        None => return NetError::ShuttingDown.into(),
4102    };
4103    let node = h.inner.clone();
4104    match block_on(async move { node.release_island(island).await }) {
4105        Ok(outcome) => {
4106            unsafe {
4107                *out_outcome = claim_outcome_code(outcome);
4108            }
4109            0
4110        }
4111        Err(e) => adapter_err_to_code(&e),
4112    }
4113}
4114
4115/// Match + reserve the first available island. On success `*out_found`
4116/// is 1 and `*out_island` holds the id, or `*out_found` is 0 when
4117/// nothing matched / all contended.
4118#[unsafe(no_mangle)]
4119pub unsafe extern "C" fn net_mesh_claim_island(
4120    handle: *mut MeshNodeHandle,
4121    criteria_json: *const c_char,
4122    until_unix_us: u64,
4123    out_found: *mut c_int,
4124    out_island: *mut u64,
4125) -> c_int {
4126    if handle.is_null() || criteria_json.is_null() || out_found.is_null() || out_island.is_null() {
4127        return NetError::NullPointer.into();
4128    }
4129    // Pre-zero both out-params so every non-error return leaves them
4130    // deterministic — a caller that reads `out_island` without first
4131    // checking `out_found` sees 0, not stale stack data. The success arm
4132    // overwrites them.
4133    unsafe {
4134        *out_found = 0;
4135        *out_island = 0;
4136    }
4137    let h = unsafe { &*handle };
4138    let _op = match h.guard.try_enter() {
4139        Some(op) => op,
4140        None => return NetError::ShuttingDown.into(),
4141    };
4142    let Some(js) = (unsafe { c_str_to_string(criteria_json) }) else {
4143        return NetError::InvalidUtf8.into();
4144    };
4145    let parsed: GangCriteriaJson = match serde_json::from_str(&js) {
4146        Ok(c) => c,
4147        Err(_) => return NET_ERR_GANG_INVALID,
4148    };
4149    let Some(criteria) = build_gang_criteria(parsed) else {
4150        return NET_ERR_GANG_INVALID;
4151    };
4152    let node = h.inner.clone();
4153    match block_on(async move { node.claim_island(&criteria, until_unix_us).await }) {
4154        Ok(Some(id)) => {
4155            unsafe {
4156                *out_found = 1;
4157                *out_island = id;
4158            }
4159            0
4160        }
4161        Ok(None) => 0,
4162        Err(e) => adapter_err_to_code(&e),
4163    }
4164}
4165
4166fn claim_outcome_code(o: crate::adapter::net::behavior::gang::ClaimOutcome) -> c_int {
4167    use crate::adapter::net::behavior::gang::ClaimOutcome;
4168    match o {
4169        ClaimOutcome::Won => 0,
4170        ClaimOutcome::Lost => 1,
4171    }
4172}
4173
4174#[cfg(test)]
4175mod tests {
4176    use super::*;
4177
4178    /// Scope filters that deserialize cleanly but carry no usable
4179    /// selector must return `InvalidArgument`, not resolve to the
4180    /// broadest filter.
4181    ///
4182    /// Pre-fix these all produced `ScopeFilterOwned::Any` — every
4183    /// non-`SubnetLocal` peer in the mesh — so a caller whose tenant id
4184    /// arrived empty silently queried everything and selected a
4185    /// provider from it
4186    /// (SECURITY_AUDIT_2026_07_31_SCOPED_CAPABILITIES.md).
4187    mod scope_filter_rejects_unusable {
4188        use super::super::{scope_filter_from_json, ScopeFilterJson, ScopeFilterOwned};
4189        use crate::ffi::NetError;
4190
4191        fn kind(kind: &str) -> ScopeFilterJson {
4192            ScopeFilterJson {
4193                kind: kind.into(),
4194                tenant: None,
4195                tenants: None,
4196                region: None,
4197                regions: None,
4198            }
4199        }
4200
4201        #[test]
4202        fn unknown_kind_is_invalid_argument() {
4203            assert!(matches!(
4204                scope_filter_from_json(kind("tenat")),
4205                Err(NetError::InvalidArgument)
4206            ));
4207        }
4208
4209        #[test]
4210        fn missing_or_empty_selectors_are_invalid_argument() {
4211            let cases = vec![
4212                kind("tenant"),
4213                ScopeFilterJson {
4214                    tenant: Some(String::new()),
4215                    ..kind("tenant")
4216                },
4217                kind("tenants"),
4218                ScopeFilterJson {
4219                    tenants: Some(vec![String::new()]),
4220                    ..kind("tenants")
4221                },
4222                kind("region"),
4223                ScopeFilterJson {
4224                    region: Some(String::new()),
4225                    ..kind("region")
4226                },
4227                kind("regions"),
4228                ScopeFilterJson {
4229                    regions: Some(vec![String::new(), String::new()]),
4230                    ..kind("regions")
4231                },
4232            ];
4233            for case in cases {
4234                let label = case.kind.clone();
4235                assert!(
4236                    matches!(scope_filter_from_json(case), Err(NetError::InvalidArgument)),
4237                    "kind {label:?} with an unusable selector must be \
4238                     InvalidArgument, not a silent widen to Any"
4239                );
4240            }
4241        }
4242
4243        /// Both spellings resolve, and the selector-bearing kinds still
4244        /// work with empty entries stripped.
4245        #[test]
4246        fn usable_filters_still_convert() {
4247            assert!(matches!(
4248                scope_filter_from_json(kind("any")),
4249                Ok(ScopeFilterOwned::Any)
4250            ));
4251            for k in ["global_only", "globalOnly"] {
4252                assert!(matches!(
4253                    scope_filter_from_json(kind(k)),
4254                    Ok(ScopeFilterOwned::GlobalOnly)
4255                ));
4256            }
4257            assert!(matches!(
4258                scope_filter_from_json(ScopeFilterJson {
4259                    tenants: Some(vec![String::new(), "oem-123".into()]),
4260                    ..kind("tenants")
4261                }),
4262                Ok(ScopeFilterOwned::Tenants(ts)) if ts == vec!["oem-123".to_string()]
4263            ));
4264        }
4265    }
4266
4267    /// ABI parity between the Rust `#[repr(C)] NetTraversalStatsV2` and
4268    /// the hand-maintained C header `include/net.go.h`. The Go guard
4269    /// `go/header_parity_test.go` compares the two C headers against
4270    /// each other, but nothing checked either against the Rust struct —
4271    /// so a field reordered / retyped / added on one side but not the
4272    /// other is silent cgo ABI corruption (Go reads at the wrong
4273    /// offsets) with no compile error and no test failure (review #5).
4274    #[cfg(feature = "nat-traversal")]
4275    mod traversal_stats_abi {
4276        use super::super::NetTraversalStatsV2;
4277        use std::mem::{align_of, offset_of, size_of};
4278
4279        /// Byte offset of a struct field by its C name. `offset_of!`
4280        /// needs a literal field ident, so this match is the one
4281        /// hand-maintained seam: a renamed or removed field fails to
4282        /// compile here until it's updated.
4283        fn rust_offset(name: &str) -> Option<usize> {
4284            Some(match name {
4285                "punches_attempted" => offset_of!(NetTraversalStatsV2, punches_attempted),
4286                "punches_succeeded" => offset_of!(NetTraversalStatsV2, punches_succeeded),
4287                "punches_failed" => offset_of!(NetTraversalStatsV2, punches_failed),
4288                "relay_fallbacks" => offset_of!(NetTraversalStatsV2, relay_fallbacks),
4289                "punch_timeouts" => offset_of!(NetTraversalStatsV2, punch_timeouts),
4290                "punch_rejections" => offset_of!(NetTraversalStatsV2, punch_rejections),
4291                "rendezvous_no_relay" => offset_of!(NetTraversalStatsV2, rendezvous_no_relay),
4292                "upgrades_attempted" => offset_of!(NetTraversalStatsV2, upgrades_attempted),
4293                "upgrades_succeeded" => offset_of!(NetTraversalStatsV2, upgrades_succeeded),
4294                "upgrades_deferred_busy" => offset_of!(NetTraversalStatsV2, upgrades_deferred_busy),
4295                "port_mapping_renewals" => offset_of!(NetTraversalStatsV2, port_mapping_renewals),
4296                "port_mapping_active" => offset_of!(NetTraversalStatsV2, port_mapping_active),
4297                "port_mapping_external" => offset_of!(NetTraversalStatsV2, port_mapping_external),
4298                _ => return None,
4299            })
4300        }
4301
4302        /// (size, align) of a C scalar/array type as spelled in the
4303        /// header. Derived from the Rust primitive each field maps to,
4304        /// NOT hardcoded: `uint64_t` is not 8-byte-aligned on every C
4305        /// ABI (x86-32 System V aligns it to 4), and a `#[repr(C)]`
4306        /// struct follows that same target ABI — so hardcoding 8 here
4307        /// would false-fail the offset/size cross-check on 32-bit
4308        /// targets where the header and Rust struct are in fact
4309        /// compatible. Panics on an unrecognized type so a
4310        /// newly-introduced field type forces this table to be extended.
4311        fn c_type_layout(ctype: &str) -> (usize, usize) {
4312            use std::mem::{align_of, size_of};
4313            use std::os::raw::c_char;
4314            match ctype {
4315                "uint64_t" => (size_of::<u64>(), align_of::<u64>()),
4316                "uint8_t" => (size_of::<u8>(), align_of::<u8>()),
4317                "char[64]" => (size_of::<c_char>() * 64, align_of::<c_char>()),
4318                other => panic!("unhandled C type in net_traversal_stats_v2_t: {other:?}"),
4319            }
4320        }
4321
4322        fn round_up(off: usize, align: usize) -> usize {
4323            off.div_ceil(align) * align
4324        }
4325
4326        /// Ordered `(ctype, name)` fields of the anonymous
4327        /// `net_traversal_stats_v2_t` struct body. `char x[64]` folds
4328        /// to ctype `char[64]`, name `x`.
4329        fn parse_header_fields(header: &str) -> Vec<(String, String)> {
4330            let end = header
4331                .find("} net_traversal_stats_v2_t;")
4332                .expect("stats typedef present in header");
4333            let open = header[..end].rfind('{').expect("struct open brace");
4334            let mut fields = Vec::new();
4335            for line in header[open + 1..end].lines() {
4336                let line = line.trim();
4337                if line.is_empty()
4338                    || line.starts_with("//")
4339                    || line.starts_with('*')
4340                    || line.starts_with("/*")
4341                {
4342                    continue;
4343                }
4344                let decl = line.trim_end_matches(';').trim();
4345                let (ctype, name_arr) = decl
4346                    .rsplit_once(char::is_whitespace)
4347                    .expect("field decl shaped `type name`");
4348                let (ctype, name_arr) = (ctype.trim(), name_arr.trim());
4349                if let Some((name, arr)) = name_arr.split_once('[') {
4350                    fields.push((format!("{ctype}[{arr}"), name.to_string()));
4351                } else {
4352                    fields.push((ctype.to_string(), name_arr.to_string()));
4353                }
4354            }
4355            fields
4356        }
4357
4358        #[test]
4359        fn c_header_layout_matches_rust_repr_c() {
4360            let header =
4361                std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/include/net.go.h"))
4362                    .expect("read include/net.go.h");
4363            let fields = parse_header_fields(&header);
4364            assert_eq!(
4365                fields.len(),
4366                13,
4367                "expected 13 fields in net_traversal_stats_v2_t, parsed {fields:?}",
4368            );
4369
4370            // Recompute the C struct layout with C alignment rules and
4371            // cross-check every field offset against the Rust struct.
4372            // Catches reorder (offsets shift), retype (offset/size
4373            // shift), and add/remove (size or name-resolution mismatch).
4374            let mut off = 0usize;
4375            let mut align = 1usize;
4376            for (ctype, name) in &fields {
4377                let (sz, al) = c_type_layout(ctype);
4378                off = round_up(off, al);
4379                align = align.max(al);
4380                let rust = rust_offset(name)
4381                    .unwrap_or_else(|| panic!("header field `{name}` has no Rust struct field"));
4382                assert_eq!(
4383                    rust, off,
4384                    "field `{name}`: Rust offset {rust} != C offset {off}"
4385                );
4386                off += sz;
4387            }
4388            assert_eq!(
4389                size_of::<NetTraversalStatsV2>(),
4390                round_up(off, align),
4391                "net_traversal_stats_v2_t total size drift (Rust vs C header)",
4392            );
4393            assert_eq!(
4394                align_of::<NetTraversalStatsV2>(),
4395                align,
4396                "net_traversal_stats_v2_t alignment drift (Rust vs C header)",
4397            );
4398        }
4399    }
4400
4401    /// Regression for a cubic-flagged P2: Go-supplied JSON values
4402    /// wider than u16::MAX silently wrapped via `as u16` in
4403    /// `gpu_info_from_json` / `accelerator_from_json` /
4404    /// `hardware_from_json`, turning 65536 cores into 0. Every
4405    /// conversion site now routes through `saturating_u16_cap`.
4406    ///
4407    /// The NAPI binding has parallel end-to-end tests on
4408    /// `hardware_from_js`; the Go side verifies saturation in
4409    /// its own integration suite by round-tripping an overflow
4410    /// announcement through `announce_capabilities` (separate
4411    /// file).
4412    #[test]
4413    fn saturating_u16_cap_clamps_at_u16_max() {
4414        assert_eq!(saturating_u16_cap(0), 0);
4415        assert_eq!(saturating_u16_cap(42), 42);
4416        assert_eq!(saturating_u16_cap(u16::MAX as u32), u16::MAX);
4417        assert_eq!(saturating_u16_cap(u16::MAX as u32 + 1), u16::MAX);
4418        assert_eq!(saturating_u16_cap(u32::MAX), u16::MAX);
4419    }
4420
4421    /// The shared pubkey parser behind every `net_mesh_connect*`
4422    /// entry point: valid 64-char hex round-trips; non-hex, wrong
4423    /// length, and non-UTF-8 inputs return the exact codes the
4424    /// wrappers historically produced inline. One implementation =
4425    /// the three wrappers can't drift apart (cubic P2).
4426    #[test]
4427    fn parse_peer_pubkey_hex_accepts_valid_and_rejects_malformed() {
4428        use std::ffi::CString;
4429
4430        let valid = CString::new("ab".repeat(32)).unwrap();
4431        // SAFETY: valid NUL-terminated pointer for the call's lifetime.
4432        let parsed = unsafe { parse_peer_pubkey_hex(valid.as_ptr()) };
4433        assert_eq!(parsed, Ok([0xABu8; 32]), "64-char hex round-trips");
4434
4435        let bad_hex = CString::new("zz".repeat(32)).unwrap();
4436        // SAFETY: as above.
4437        let err = unsafe { parse_peer_pubkey_hex(bad_hex.as_ptr()) };
4438        assert_eq!(err, Err(NET_ERR_MESH_HANDSHAKE), "non-hex rejects");
4439
4440        let short = CString::new("abcd").unwrap();
4441        // SAFETY: as above.
4442        let err = unsafe { parse_peer_pubkey_hex(short.as_ptr()) };
4443        assert_eq!(err, Err(NET_ERR_MESH_HANDSHAKE), "wrong length rejects");
4444
4445        // Valid CString bytes, invalid UTF-8 → the UTF-8 code.
4446        let non_utf8 = CString::new(vec![0xFFu8, 0xFEu8]).unwrap();
4447        // SAFETY: as above.
4448        let err = unsafe { parse_peer_pubkey_hex(non_utf8.as_ptr()) };
4449        assert_eq!(
4450            err,
4451            Err(NetError::InvalidUtf8.into()),
4452            "non-UTF-8 C string rejects with the UTF-8 code",
4453        );
4454    }
4455
4456    /// The v2 stats fill maps every core-snapshot field into the
4457    /// C-ABI struct, encodes the external address as a
4458    /// NUL-terminated string, and leaves the buffer empty when no
4459    /// mapping is active. Pins the field mapping so an added core
4460    /// field that's forgotten here shows up as a compile error
4461    /// (struct literal) or a failing assert (value).
4462    #[cfg(feature = "nat-traversal")]
4463    #[test]
4464    fn traversal_stats_v2_fill_maps_all_fields() {
4465        use crate::adapter::net::traversal::TraversalStatsSnapshot;
4466
4467        let snap = TraversalStatsSnapshot {
4468            punches_attempted: 1,
4469            punches_succeeded: 2,
4470            relay_fallbacks: 3,
4471            port_mapping_active: true,
4472            port_mapping_external: Some("203.0.113.5:4321".parse().unwrap()),
4473            port_mapping_renewals: 4,
4474            upgrades_attempted: 5,
4475            upgrades_succeeded: 6,
4476            upgrades_deferred_busy: 7,
4477            punches_failed: 8,
4478            punch_timeouts: 9,
4479            punch_rejections: 10,
4480            rendezvous_no_relay: 11,
4481        };
4482        let mut out = NetTraversalStatsV2 {
4483            punches_attempted: 0,
4484            punches_succeeded: 0,
4485            punches_failed: 0,
4486            relay_fallbacks: 0,
4487            punch_timeouts: 0,
4488            punch_rejections: 0,
4489            rendezvous_no_relay: 0,
4490            upgrades_attempted: 0,
4491            upgrades_succeeded: 0,
4492            upgrades_deferred_busy: 0,
4493            port_mapping_renewals: 0,
4494            port_mapping_active: 0,
4495            port_mapping_external: [0x7F; 64], // poisoned: fill must clear
4496        };
4497        fill_traversal_stats_v2(&snap, &mut out);
4498
4499        assert_eq!(out.punches_attempted, 1);
4500        assert_eq!(out.punches_succeeded, 2);
4501        assert_eq!(out.relay_fallbacks, 3);
4502        assert_eq!(out.port_mapping_renewals, 4);
4503        assert_eq!(out.upgrades_attempted, 5);
4504        assert_eq!(out.upgrades_succeeded, 6);
4505        assert_eq!(out.upgrades_deferred_busy, 7);
4506        assert_eq!(out.punches_failed, 8);
4507        assert_eq!(out.punch_timeouts, 9);
4508        assert_eq!(out.punch_rejections, 10);
4509        assert_eq!(out.rendezvous_no_relay, 11);
4510        assert_eq!(out.port_mapping_active, 1);
4511        let s: String = out
4512            .port_mapping_external
4513            .iter()
4514            .take_while(|&&c| c != 0)
4515            .map(|&c| c as u8 as char)
4516            .collect();
4517        assert_eq!(s, "203.0.113.5:4321");
4518        // NUL-terminated within the buffer.
4519        assert!(out.port_mapping_external.contains(&0));
4520
4521        // Inactive mapping → empty string, active = 0.
4522        let snap_off = TraversalStatsSnapshot {
4523            port_mapping_active: false,
4524            port_mapping_external: None,
4525            ..snap
4526        };
4527        fill_traversal_stats_v2(&snap_off, &mut out);
4528        assert_eq!(out.port_mapping_active, 0);
4529        assert_eq!(
4530            out.port_mapping_external[0], 0,
4531            "empty string when inactive"
4532        );
4533    }
4534
4535    /// Regression: `parse_modality_cap` must surface unknown
4536    /// modality strings as `None`, not silently fall back to
4537    /// `Modality::Text`. Pre-fix a typo in announce-capabilities
4538    /// like `"audoi"` advertised a Text capability the node
4539    /// didn't have; in find-nodes filters, the same typo was
4540    /// reinterpreted as `require Text` and returned the wrong
4541    /// nodes. The strict shape lets callers handle the unknown
4542    /// case explicitly (callers in this file warn-and-skip).
4543    #[test]
4544    fn parse_modality_cap_returns_none_on_unknown_strings() {
4545        // Known values still parse.
4546        for (s, expected) in [
4547            ("text", Modality::Text),
4548            ("Text", Modality::Text),
4549            ("TEXT", Modality::Text),
4550            ("image", Modality::Image),
4551            ("audio", Modality::Audio),
4552            ("video", Modality::Video),
4553            ("code", Modality::Code),
4554            ("embedding", Modality::Embedding),
4555            ("tool-use", Modality::ToolUse),
4556            ("tool_use", Modality::ToolUse),
4557            ("tooluse", Modality::ToolUse),
4558        ] {
4559            assert_eq!(
4560                parse_modality_cap(s),
4561                Some(expected),
4562                "known modality `{s}` must parse",
4563            );
4564        }
4565
4566        // Typos and unknowns return None, NOT Modality::Text.
4567        for s in ["audoi", "imageX", "vidoe", "embeding", "garbage", ""] {
4568            assert_eq!(
4569                parse_modality_cap(s),
4570                None,
4571                "unknown modality `{s}` must return None — pre-fix this \
4572                 fell back to Modality::Text, advertising a capability \
4573                 the node didn't actually have",
4574            );
4575        }
4576    }
4577
4578    /// Regression: `gpu_info_from_json` must saturate large
4579    /// `fp16_tflops_x10` values at `u16::MAX` before the f32
4580    /// conversion. Pre-fix `tf as f32` lost precision for u32
4581    /// values above 2²⁴ (f32 has a 24-bit mantissa) — the
4582    /// round-trip `u32 → f32/10.0 → with_fp16_tflops → *10.0
4583    /// as u32` could land a different `fp16_tflops_x10` than
4584    /// the operator declared. The matching saturation aligns
4585    /// with the neighboring `tops_x10` field's surface and
4586    /// keeps the round-trip exact.
4587    #[test]
4588    fn gpu_info_from_json_saturates_fp16_tflops_to_u16_max() {
4589        // A hostile or just unrealistically large value well
4590        // above the f32 precision boundary (2^24 = 16_777_216).
4591        let g = GpuJson {
4592            vendor: None,
4593            model: "test".to_string(),
4594            vram_gb: 0,
4595            compute_units: None,
4596            tensor_cores: None,
4597            fp16_tflops_x10: Some(1_000_000_000u32),
4598        };
4599        let info = gpu_info_from_json(g);
4600        // The cap is u16::MAX = 65535; the f32 round-trip back to
4601        // x10 storage must reproduce 65_535, NOT some lossily
4602        // rounded approximation of 1_000_000_000.
4603        assert_eq!(
4604            info.fp16_tflops_x10,
4605            u16::MAX as u32,
4606            "fp16_tflops_x10 must saturate at u16::MAX (65535) instead of \
4607             losing precision through the f32 round-trip; got {}",
4608            info.fp16_tflops_x10,
4609        );
4610
4611        // Sanity: a small in-range value round-trips exactly.
4612        let g_small = GpuJson {
4613            vendor: None,
4614            model: "test".to_string(),
4615            vram_gb: 0,
4616            compute_units: None,
4617            tensor_cores: None,
4618            fp16_tflops_x10: Some(425), // 42.5 TFLOPS
4619        };
4620        let info_small = gpu_info_from_json(g_small);
4621        assert_eq!(
4622            info_small.fp16_tflops_x10, 425,
4623            "small fp16_tflops_x10 must round-trip exactly"
4624        );
4625    }
4626
4627    /// Regression: `alloc_bytes` used to call `Vec::shrink_to_fit`
4628    /// and then hand the raw `(ptr, len)` to C, expecting
4629    /// `net_free_bytes` to reconstruct with
4630    /// `Vec::from_raw_parts(ptr, len, len)`. `shrink_to_fit` is not
4631    /// guaranteed to make `capacity == len`, so the reconstruction
4632    /// could UB on drop (allocator size mismatch). The fix uses
4633    /// `Layout::array::<u8>(len)` on both sides so the capacity is
4634    /// always exactly `len`.
4635    ///
4636    /// This test exercises the alloc/free round-trip across a range
4637    /// of sizes; under miri (or with the system allocator) any size
4638    /// mismatch would surface here.
4639    #[test]
4640    fn alloc_bytes_round_trip_across_sizes() {
4641        for size in [0usize, 1, 15, 16, 17, 32, 64, 1024, 8192] {
4642            let src: Vec<u8> = (0..size).map(|i| (i as u8).wrapping_mul(37)).collect();
4643            let mut ptr: *mut u8 = std::ptr::null_mut();
4644            let mut len: usize = 0;
4645            let rc = alloc_bytes(&src, &mut ptr as *mut _, &mut len as *mut _);
4646            assert_eq!(rc, 0);
4647            assert_eq!(len, size);
4648            if size == 0 {
4649                assert!(ptr.is_null());
4650            } else {
4651                assert!(!ptr.is_null());
4652                let observed = unsafe { std::slice::from_raw_parts(ptr, len) };
4653                assert_eq!(observed, &src[..]);
4654            }
4655            // Freeing with a null or zero-len must be a no-op; freeing
4656            // a real buffer must not abort or corrupt the allocator.
4657            unsafe { net_free_bytes(ptr, len) };
4658        }
4659    }
4660
4661    #[test]
4662    fn net_free_bytes_null_and_zero_len_are_noops() {
4663        // Both explicitly documented as safe no-ops.
4664        unsafe { net_free_bytes(std::ptr::null_mut(), 0) };
4665        unsafe { net_free_bytes(std::ptr::null_mut(), 42) };
4666        // A non-null pointer with len == 0 is also a no-op — we must
4667        // not try to free it, since we never allocated.
4668        let mut sentinel: u8 = 0;
4669        unsafe { net_free_bytes(&mut sentinel as *mut u8, 0) };
4670    }
4671
4672    /// `net_free_bytes` must NOT panic when called with a
4673    /// `len` larger than `isize::MAX`. Pre-fix
4674    /// `Layout::array::<u8>(len).expect(...)` panicked on such
4675    /// values (a documented `Layout::array` failure mode); the
4676    /// panic would unwind across the `extern "C"` boundary into
4677    /// any non-Rust caller (C / Go-cgo / NAPI / PyO3) — undefined
4678    /// behaviour. Now the function silently no-ops on
4679    /// `Layout::array` failure: an allocation of that size could
4680    /// not have come from this process under matching layout
4681    /// rules, so it's already memory-corruption territory and
4682    /// abandoning the free is the safest response.
4683    #[test]
4684    fn net_free_bytes_does_not_panic_on_oversized_len() {
4685        // We can't actually allocate a buffer of `isize::MAX + 1`
4686        // bytes to free; the fix's load-bearing check is that the
4687        // function reaches the `Err(_) => return` branch instead
4688        // of panicking. Pass a non-null pointer with an oversized
4689        // len; with the old `expect("byte layout")` this panics.
4690        // We use a stack sentinel as the pointer — the function
4691        // must short-circuit without touching it.
4692        let mut sentinel: u8 = 0;
4693        let ptr = &mut sentinel as *mut u8;
4694        // `usize::MAX` is well past `isize::MAX`, so
4695        // `Layout::array::<u8>(usize::MAX)` is `Err(LayoutError)`.
4696        unsafe { net_free_bytes(ptr, usize::MAX) };
4697        // If we got here without panicking, the fix is in place.
4698        // Sentinel must still be untouched (we never tried to free).
4699        assert_eq!(sentinel, 0, "sentinel must not have been written through");
4700    }
4701
4702    /// Regression for a cubic-flagged P1: `net_mesh_shutdown`
4703    /// previously returned success (0) without actually shutting
4704    /// the node down whenever `Arc::strong_count(&inner) > 1`
4705    /// (e.g. the FFI caller was holding a stream handle). The real
4706    /// shutdown was silently skipped, so background tasks kept
4707    /// draining UDP and consuming CPU. This test holds an extra
4708    /// `Arc` clone, calls `net_mesh_shutdown`, and asserts the
4709    /// shutdown flag flipped.
4710    #[test]
4711    fn net_mesh_shutdown_runs_even_with_outstanding_arc_refs() {
4712        let cfg = serde_json::json!({
4713            "bind_addr": "127.0.0.1:0",
4714            "psk_hex": "0".repeat(64),
4715        });
4716        let cfg_c = CString::new(cfg.to_string()).unwrap();
4717        let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
4718        let rc = unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) };
4719        assert_eq!(rc, 0, "net_mesh_new failed: {rc}");
4720        assert!(!out.is_null());
4721
4722        // Clone the inner Arc so strong_count > 1 — this is what a
4723        // live stream handle would look like from the guard's POV.
4724        let inner_clone = {
4725            let h = unsafe { &*out };
4726            Arc::clone(&h.inner)
4727        };
4728        assert!(Arc::strong_count(&inner_clone) >= 2);
4729        assert!(!inner_clone.is_shutdown());
4730
4731        let rc = unsafe { net_mesh_shutdown(out) };
4732        assert_eq!(rc, 0, "net_mesh_shutdown returned {rc}");
4733        assert!(
4734            inner_clone.is_shutdown(),
4735            "shutdown flag must be set even when extra Arc refs are outstanding"
4736        );
4737
4738        drop(inner_clone);
4739        // Use the production _free; it drains via HandleGuard and
4740        // takes inner. The outer box is intentionally leaked
4741        // (small per-call leak; acceptable in tests).
4742        unsafe { net_mesh_free(out) };
4743    }
4744
4745    /// G-prov (§D1a): the FFI mesh constructor — the code path Go's
4746    /// `NewMeshNode` rides — must record identity provenance so the org
4747    /// facade can refuse to bind an ephemeral node. A caller-supplied
4748    /// `identity_seed_hex` is a durable identity (`has_configured_identity()`
4749    /// true); its absence is a generated ephemeral fallback (false). The napi
4750    /// and PyO3 constructors each silently omitted this and refused a seeded
4751    /// caller `persistent_identity_required` until fixed; this is the third
4752    /// constructor and the witness that closes the same gap for Go.
4753    #[test]
4754    fn net_mesh_new_records_identity_provenance() {
4755        // Seeded → configured.
4756        let cfg = serde_json::json!({
4757            "bind_addr": "127.0.0.1:0",
4758            "psk_hex": "0".repeat(64),
4759            "identity_seed_hex": "7a".repeat(32),
4760        });
4761        let cfg_c = CString::new(cfg.to_string()).unwrap();
4762        let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
4763        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) }, 0);
4764        assert!(
4765            unsafe { &*out }.inner.has_configured_identity(),
4766            "a caller-supplied identity_seed_hex must set configured_identity"
4767        );
4768        unsafe { net_mesh_free(out) };
4769
4770        // No seed → ephemeral fallback, NOT configured.
4771        let cfg = serde_json::json!({
4772            "bind_addr": "127.0.0.1:0",
4773            "psk_hex": "0".repeat(64),
4774        });
4775        let cfg_c = CString::new(cfg.to_string()).unwrap();
4776        let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
4777        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) }, 0);
4778        assert!(
4779            !unsafe { &*out }.inner.has_configured_identity(),
4780            "a generated ephemeral fallback must leave configured_identity false"
4781        );
4782        unsafe { net_mesh_free(out) };
4783    }
4784
4785    /// Regression: BUG_REPORT.md #19 — `net_mesh_send` family
4786    /// accepted any `(MeshStreamHandle, MeshNodeHandle)` pair and
4787    /// sent through the supplied node, regardless of whether the
4788    /// stream was opened on it. The fix uses `Arc::ptr_eq` to
4789    /// require the stream's cached `_node` to match the supplied
4790    /// node handle's inner `Arc`.
4791    ///
4792    /// Build two distinct nodes via the FFI constructor (so all
4793    /// the internal fields are populated correctly), open a stream
4794    /// on the first, then verify `handles_match` accepts the
4795    /// matched pair and rejects the cross-pair.
4796    #[test]
4797    fn handles_match_rejects_stream_node_mismatch() {
4798        fn make_node_handle() -> *mut MeshNodeHandle {
4799            let cfg = serde_json::json!({
4800                "bind_addr": "127.0.0.1:0",
4801                "psk_hex": "0".repeat(64),
4802            });
4803            let cfg_c = CString::new(cfg.to_string()).unwrap();
4804            let mut out: *mut MeshNodeHandle = std::ptr::null_mut();
4805            let rc = unsafe { net_mesh_new(cfg_c.as_ptr(), &mut out) };
4806            assert_eq!(rc, 0);
4807            assert!(!out.is_null());
4808            out
4809        }
4810
4811        let nh_a = make_node_handle();
4812        let nh_b = make_node_handle();
4813
4814        // Build a stream handle whose `_node` Arc is node_a's
4815        // inner. We can't go through `open_stream` here because
4816        // that requires an established session with the peer
4817        // (which the unit test can't synthesize), but `handles_match`
4818        // only inspects the cached `_node` Arc — the stream fields
4819        // are irrelevant to the check. Direct field init is fine
4820        // since we're in the same module.
4821        let sh_a = {
4822            let h = unsafe { &*nh_a };
4823            let node_clone: Arc<MeshNode> = Arc::clone(&h.inner);
4824            MeshStreamHandle {
4825                stream: ManuallyDrop::new(CoreStream {
4826                    peer_node_id: 0xDEAD,
4827                    stream_id: 1,
4828                    epoch: 0,
4829                    config: StreamConfig::new(),
4830                }),
4831                _node: ManuallyDrop::new(node_clone),
4832                guard: HandleGuard::new(),
4833            }
4834        };
4835
4836        // Matched pair: stream's _node == nh_a.inner — accepted.
4837        assert!(
4838            handles_match(&sh_a, unsafe { &*nh_a }),
4839            "stream from node_a + node_a handle must match"
4840        );
4841        // Mismatched pair: stream's _node != nh_b.inner — rejected.
4842        assert!(
4843            !handles_match(&sh_a, unsafe { &*nh_b }),
4844            "stream from node_a + node_b handle must be rejected (#19)"
4845        );
4846
4847        // Cleanup: take ManuallyDrop inner fields out of sh_a so
4848        // they're properly dropped (rather than leaking when sh_a
4849        // falls out of scope). Then call production _free on the
4850        // node handles (drains via HandleGuard; leaks the outer
4851        // boxes per the soundness rule — acceptable for tests).
4852        // SAFETY: sh_a was just built on this thread; no
4853        // concurrent access; ManuallyDrop fields haven't been
4854        // taken yet.
4855        unsafe {
4856            let mut sh_a = sh_a;
4857            let _ = ManuallyDrop::take(&mut sh_a.stream);
4858            let _ = ManuallyDrop::take(&mut sh_a._node);
4859        }
4860        unsafe { net_mesh_free(nh_a) };
4861        unsafe { net_mesh_free(nh_b) };
4862    }
4863
4864    /// `net_mesh_free` must be idempotent — the post-fix protocol
4865    /// does `if begin_free { ManuallyDrop::take(...) }`, so a
4866    /// second call must observe `freeing=true` and skip the take
4867    /// branch (taking again would panic since `ManuallyDrop` is
4868    /// already moved out). The `HandleGuard` core test pins the
4869    /// protocol; this test pins the per-handle wiring is correct.
4870    #[test]
4871    fn net_mesh_free_is_idempotent() {
4872        let cfg = serde_json::json!({
4873            "bind_addr": "127.0.0.1:0",
4874            "psk_hex": "0".repeat(64),
4875        });
4876        let cfg_c = CString::new(cfg.to_string()).unwrap();
4877        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
4878        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
4879        assert!(!nh.is_null());
4880
4881        unsafe { net_mesh_free(nh) };
4882        // Second free: must not panic, must not double-take the
4883        // ManuallyDrop fields, must not deallocate the (leaked)
4884        // outer box.
4885        unsafe { net_mesh_free(nh) };
4886    }
4887
4888    /// `net_identity_free` must be idempotent; same wiring check
4889    /// as `net_mesh_free_is_idempotent` for the IdentityHandle
4890    /// (which holds keypair + cache in `ManuallyDrop`).
4891    #[test]
4892    fn net_identity_free_is_idempotent() {
4893        let mut h: *mut IdentityHandle = std::ptr::null_mut();
4894        assert_eq!(unsafe { net_identity_generate(&mut h) }, 0);
4895        assert!(!h.is_null());
4896
4897        unsafe { net_identity_free(h) };
4898        // Second free: must not panic.
4899        unsafe { net_identity_free(h) };
4900    }
4901
4902    /// `net_mesh_free` racing an in-flight op via the same handle
4903    /// must wait for the op to drop its `try_enter` guard before
4904    /// taking the inner. Without the guard, `_free` would proceed
4905    /// immediately and the op's subsequent inner deref would UAF.
4906    ///
4907    /// We exercise the guard directly (rather than through a
4908    /// long-running FFI op) so the timing window is deterministic
4909    /// and not dependent on real network / IO latency. The
4910    /// worker holds a `try_enter` op until released; main thread
4911    /// calls `_free`, which post-fix must block on `begin_free`'s
4912    /// drain loop until the worker drops the op.
4913    #[test]
4914    fn net_mesh_free_waits_for_inflight_op() {
4915        use std::sync::atomic::{AtomicBool, Ordering};
4916        use std::time::{Duration, Instant};
4917
4918        let cfg = serde_json::json!({
4919            "bind_addr": "127.0.0.1:0",
4920            "psk_hex": "0".repeat(64),
4921        });
4922        let cfg_c = CString::new(cfg.to_string()).unwrap();
4923        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
4924        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
4925        assert!(!nh.is_null());
4926
4927        // Smuggle the raw pointer to the worker via usize (same
4928        // shape as cortex's `redex_file_free_waits_for_inflight_append`).
4929        let nh_addr = nh as usize;
4930        let started = Arc::new(AtomicBool::new(false));
4931        let release = Arc::new(AtomicBool::new(false));
4932        let started_w = started.clone();
4933        let release_w = release.clone();
4934
4935        let worker = std::thread::spawn(move || {
4936            let h = unsafe { &*(nh_addr as *mut MeshNodeHandle) };
4937            // Take the guard directly — every gated FFI entry
4938            // point does this internally. Holding it past the
4939            // main thread's begin_free is what we're testing.
4940            let op = h.guard.try_enter().expect("entry must succeed pre-free");
4941            started_w.store(true, Ordering::SeqCst);
4942            while !release_w.load(Ordering::SeqCst) {
4943                std::thread::sleep(Duration::from_millis(1));
4944            }
4945            drop(op);
4946        });
4947
4948        // Wait for the worker to enter the op.
4949        while !started.load(Ordering::SeqCst) {
4950            std::thread::yield_now();
4951        }
4952
4953        // Schedule release ~50ms out so begin_free has time to
4954        // observe `active_ops > 0` and enter its drain loop.
4955        let release_clone = release.clone();
4956        std::thread::spawn(move || {
4957            std::thread::sleep(Duration::from_millis(50));
4958            release_clone.store(true, Ordering::SeqCst);
4959        });
4960
4961        // _free MUST block until the worker drops its op.
4962        let t0 = Instant::now();
4963        unsafe { net_mesh_free(nh) };
4964        let elapsed = t0.elapsed();
4965        assert!(
4966            elapsed >= Duration::from_millis(40),
4967            "net_mesh_free returned in {:?} — pre-fix it would have proceeded \
4968             immediately and the worker's subsequent op would UAF",
4969            elapsed,
4970        );
4971        worker.join().unwrap();
4972    }
4973
4974    /// Post-free `net_mesh_stream_stats` must bail with
4975    /// ShuttingDown rather than touching the freed
4976    /// `inner: ManuallyDrop<Arc<MeshNode>>`. Without the guard,
4977    /// the function would do `&*node_handle;
4978    /// h.inner.stream_stats(...)` and race UAF against
4979    /// `net_mesh_free`.
4980    #[test]
4981    fn net_mesh_stream_stats_returns_shutting_down_after_free() {
4982        let cfg = serde_json::json!({
4983            "bind_addr": "127.0.0.1:0",
4984            "psk_hex": "0".repeat(64),
4985        });
4986        let cfg_c = CString::new(cfg.to_string()).unwrap();
4987        let mut nh: *mut MeshNodeHandle = std::ptr::null_mut();
4988        assert_eq!(unsafe { net_mesh_new(cfg_c.as_ptr(), &mut nh) }, 0);
4989        assert!(!nh.is_null());
4990
4991        // Free first; subsequent stream_stats must bail before
4992        // touching the taken-out inner.
4993        unsafe { net_mesh_free(nh) };
4994
4995        let mut out_json: *mut c_char = std::ptr::null_mut();
4996        let mut out_len: usize = 0;
4997        let rc = unsafe { net_mesh_stream_stats(nh, 0xDEAD, 1, &mut out_json, &mut out_len) };
4998        assert_eq!(
4999            rc,
5000            NetError::ShuttingDown as c_int,
5001            "post-free stream_stats must surface ShuttingDown (got {rc})",
5002        );
5003        assert!(
5004            out_json.is_null(),
5005            "no payload may be written after the guard fires",
5006        );
5007    }
5008
5009    /// Post-free `net_identity_issue_token` must bail with
5010    /// ShuttingDown rather than borrowing the freed keypair
5011    /// (which lives in `ManuallyDrop` and is taken out by
5012    /// `net_identity_free`).
5013    #[test]
5014    fn net_identity_issue_token_returns_shutting_down_after_free() {
5015        let mut signer: *mut IdentityHandle = std::ptr::null_mut();
5016        assert_eq!(unsafe { net_identity_generate(&mut signer) }, 0);
5017        assert!(!signer.is_null());
5018        unsafe { net_identity_free(signer) };
5019
5020        // Well-formed inputs (so we reach the guard rather than
5021        // bailing on parse).
5022        let subject = [0u8; 32];
5023        let scope = CString::new("[\"publish\"]").unwrap();
5024        let channel = CString::new("test-channel").unwrap();
5025        let mut out_token: *mut u8 = std::ptr::null_mut();
5026        let mut out_token_len: usize = 0;
5027        let rc = unsafe {
5028            net_identity_issue_token(
5029                signer,
5030                subject.as_ptr(),
5031                subject.len(),
5032                scope.as_ptr(),
5033                channel.as_ptr(),
5034                60,
5035                0,
5036                &mut out_token,
5037                &mut out_token_len,
5038            )
5039        };
5040        assert_eq!(
5041            rc,
5042            NetError::ShuttingDown as c_int,
5043            "post-free issue_token must surface ShuttingDown (got {rc})",
5044        );
5045        assert!(out_token.is_null(), "no token bytes may be allocated");
5046    }
5047
5048    /// Post-free `net_delegate_token` must bail with ShuttingDown
5049    /// rather than borrowing the freed signer keypair. The parent
5050    /// token must validate first (parse before guard), so we
5051    /// issue a real one from a live signer, then free that signer
5052    /// and reuse it as the delegating signer.
5053    #[test]
5054    fn net_delegate_token_returns_shutting_down_after_free() {
5055        let mut signer: *mut IdentityHandle = std::ptr::null_mut();
5056        assert_eq!(unsafe { net_identity_generate(&mut signer) }, 0);
5057        assert!(!signer.is_null());
5058
5059        // Issue a real parent token while signer is alive.
5060        let subject = [0u8; 32];
5061        let scope = CString::new("[\"publish\",\"delegate\"]").unwrap();
5062        let channel = CString::new("test-channel").unwrap();
5063        let mut parent_bytes: *mut u8 = std::ptr::null_mut();
5064        let mut parent_len: usize = 0;
5065        assert_eq!(
5066            unsafe {
5067                net_identity_issue_token(
5068                    signer,
5069                    subject.as_ptr(),
5070                    subject.len(),
5071                    scope.as_ptr(),
5072                    channel.as_ptr(),
5073                    60,
5074                    1,
5075                    &mut parent_bytes,
5076                    &mut parent_len,
5077                )
5078            },
5079            0,
5080        );
5081        assert!(!parent_bytes.is_null());
5082
5083        // Now free the signer and try to delegate using it.
5084        unsafe { net_identity_free(signer) };
5085
5086        let new_subject = [1u8; 32];
5087        let restricted = CString::new("[\"publish\"]").unwrap();
5088        let mut child_bytes: *mut u8 = std::ptr::null_mut();
5089        let mut child_len: usize = 0;
5090        let rc = unsafe {
5091            net_delegate_token(
5092                signer,
5093                parent_bytes,
5094                parent_len,
5095                new_subject.as_ptr(),
5096                new_subject.len(),
5097                restricted.as_ptr(),
5098                &mut child_bytes,
5099                &mut child_len,
5100            )
5101        };
5102        assert_eq!(
5103            rc,
5104            NetError::ShuttingDown as c_int,
5105            "post-free delegate_token must surface ShuttingDown (got {rc})",
5106        );
5107        assert!(child_bytes.is_null(), "no child token may be allocated");
5108
5109        // Cleanup: free the parent token bytes.
5110        unsafe { net_free_bytes(parent_bytes, parent_len) };
5111    }
5112
5113    #[test]
5114    fn hardware_from_json_saturates_overflow_cpu_fields() {
5115        // 70_000 > u16::MAX (65_535). Pre-fix: 70_000 as u16 = 4464.
5116        // Post-fix: saturates to 65_535.
5117        let h = HardwareJson {
5118            cpu_cores: Some(70_000),
5119            cpu_threads: Some(200_000),
5120            memory_gb: None,
5121            gpu: None,
5122            additional_gpus: Vec::new(),
5123            storage_gb: None,
5124            network_gbps: None,
5125            accelerators: Vec::new(),
5126        };
5127        let hw = hardware_from_json(h);
5128        assert_eq!(hw.cpu_cores, u16::MAX);
5129        assert_eq!(hw.cpu_threads, u16::MAX);
5130    }
5131
5132    /// A C caller passing `(size_t)-1` as `len` to the token-parsing
5133    /// FFI entry points previously triggered immediate UB in
5134    /// `slice::from_raw_parts` (which requires `len <= isize::MAX`).
5135    /// The guard must short-circuit with a typed error before the
5136    /// dangling pointer is dereferenced. The sentinel pointer is
5137    /// never read because the size check fires first.
5138    #[test]
5139    fn token_entry_points_reject_oversize_len() {
5140        let invalid_json: c_int = NetError::InvalidJson.into();
5141        let mut sentinel: u8 = 0;
5142        let token = &mut sentinel as *mut u8 as *const u8;
5143
5144        let mut out_json: *mut c_char = std::ptr::null_mut();
5145        let mut out_len: usize = 0;
5146        assert_eq!(
5147            unsafe { net_parse_token(token, usize::MAX, &mut out_json, &mut out_len) },
5148            invalid_json,
5149        );
5150        assert!(out_json.is_null());
5151
5152        let mut out_ok: c_int = -42;
5153        assert_eq!(
5154            unsafe { net_verify_token(token, usize::MAX, &mut out_ok) },
5155            invalid_json,
5156        );
5157
5158        let mut out_expired: c_int = -42;
5159        assert_eq!(
5160            unsafe { net_token_is_expired(token, usize::MAX, &mut out_expired) },
5161            invalid_json,
5162        );
5163
5164        assert_eq!(
5165            sentinel, 0,
5166            "sentinel must not be touched: the length guard fires before any deref"
5167        );
5168    }
5169}
5170
5171#[cfg(all(test, not(feature = "nat-traversal")))]
5172mod nat_traversal_stub_tests {
5173    //! Regression coverage for cubic-flagged P1 Bug L: the Go /
5174    //! NAPI / PyO3 bindings unconditionally link against the
5175    //! `net_mesh_nat_type` / `net_mesh_connect_direct` / ...
5176    //! symbols. Without these stubs, a cdylib built without
5177    //! `--features nat-traversal` failed at dlopen with a missing-
5178    //! symbol error, contradicting the binding docs' promise of
5179    //! `ErrTraversalUnsupported` at runtime.
5180    //!
5181    //! Each test here asserts the stub resolves *and* returns
5182    //! [`super::NET_ERR_TRAVERSAL_UNSUPPORTED`] (-137) — the exact
5183    //! value the Go / NAPI / PyO3 translation layers map to their
5184    //! respective `Unsupported` sentinels.
5185    //!
5186    //! Only compiled in the no-feature build; the feature-on path
5187    //! has different semantics (real NAT-traversal work) tested
5188    //! elsewhere.
5189    use super::*;
5190    use std::ptr;
5191
5192    #[test]
5193    fn nat_type_stub_returns_unsupported() {
5194        let mut out_str: *mut c_char = ptr::null_mut();
5195        let mut out_len: usize = 0;
5196        // SAFETY: stub path — null handle is the documented sentinel
5197        // the stub fast-paths to `NET_ERR_TRAVERSAL_UNSUPPORTED`.
5198        let code = unsafe { net_mesh_nat_type(ptr::null_mut(), &mut out_str, &mut out_len) };
5199        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5200    }
5201
5202    #[test]
5203    fn reflex_addr_stub_returns_unsupported() {
5204        let mut out_str: *mut c_char = ptr::null_mut();
5205        let mut out_len: usize = 0;
5206        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5207        let code = unsafe { net_mesh_reflex_addr(ptr::null_mut(), &mut out_str, &mut out_len) };
5208        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5209    }
5210
5211    #[test]
5212    fn peer_nat_type_stub_returns_unsupported() {
5213        let mut out_str: *mut c_char = ptr::null_mut();
5214        let mut out_len: usize = 0;
5215        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5216        let code =
5217            unsafe { net_mesh_peer_nat_type(ptr::null_mut(), 0, &mut out_str, &mut out_len) };
5218        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5219    }
5220
5221    #[test]
5222    fn probe_reflex_stub_returns_unsupported() {
5223        let mut out_str: *mut c_char = ptr::null_mut();
5224        let mut out_len: usize = 0;
5225        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5226        let code = unsafe { net_mesh_probe_reflex(ptr::null_mut(), 0, &mut out_str, &mut out_len) };
5227        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5228    }
5229
5230    #[test]
5231    fn reclassify_nat_stub_returns_unsupported() {
5232        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5233        let code = unsafe { net_mesh_reclassify_nat(ptr::null_mut()) };
5234        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5235    }
5236
5237    #[test]
5238    fn traversal_stats_stub_returns_unsupported() {
5239        let mut a: u64 = 0;
5240        let mut b: u64 = 0;
5241        let mut c: u64 = 0;
5242        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5243        let code = unsafe { net_mesh_traversal_stats(ptr::null_mut(), &mut a, &mut b, &mut c) };
5244        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5245    }
5246
5247    #[test]
5248    fn connect_direct_stub_returns_unsupported() {
5249        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5250        let code = unsafe { net_mesh_connect_direct(ptr::null_mut(), 0, ptr::null(), 0) };
5251        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5252    }
5253
5254    #[test]
5255    fn connect_direct_auto_stub_returns_unsupported() {
5256        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5257        let code = unsafe { net_mesh_connect_direct_auto(ptr::null_mut(), 0, ptr::null()) };
5258        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5259    }
5260
5261    #[test]
5262    fn traversal_stats_v2_stub_returns_unsupported() {
5263        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5264        let code = unsafe { net_mesh_traversal_stats_v2(ptr::null_mut(), ptr::null_mut()) };
5265        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5266    }
5267
5268    #[test]
5269    fn set_reflex_override_stub_returns_unsupported() {
5270        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5271        let code = unsafe { net_mesh_set_reflex_override(ptr::null_mut(), ptr::null()) };
5272        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5273    }
5274
5275    #[test]
5276    fn clear_reflex_override_stub_returns_unsupported() {
5277        // SAFETY: stub path — see `nat_type_stub_returns_unsupported`.
5278        let code = unsafe { net_mesh_clear_reflex_override(ptr::null_mut()) };
5279        assert_eq!(code, NET_ERR_TRAVERSAL_UNSUPPORTED);
5280    }
5281
5282    /// Pins the constant itself. If anyone ever renumbers
5283    /// `NET_ERR_TRAVERSAL_UNSUPPORTED`, every Go / NAPI / PyO3
5284    /// binding's error translation silently breaks — the stubs
5285    /// return the new value but the mapping layers are hardcoded
5286    /// to -137.
5287    #[test]
5288    fn unsupported_code_is_stable() {
5289        assert_eq!(NET_ERR_TRAVERSAL_UNSUPPORTED, -137);
5290    }
5291
5292    /// Repro for the failing Go `TestHardwareAndGpuFilter_Matches`:
5293    /// parse the exact JSON the Go binding marshals, convert via
5294    /// the FFI helpers, then verify the GpuVendor lands as Nvidia.
5295    #[test]
5296    fn capability_set_from_go_marshal_preserves_gpu_vendor() {
5297        let json = r#"{"hardware":{"cpu_cores":16,"memory_gb":64,"gpu":{"vendor":"nvidia","model":"h100","vram_gb":80}},"tags":["gpu"]}"#;
5298        let parsed: CapabilitySetJson = serde_json::from_str(json).expect("JSON should parse");
5299        let caps = capability_set_from_json(parsed);
5300        // Phase A.5.5: read through views() so the test asserts
5301        // the projection — the same surface every consumer sees
5302        // post-Phase-A.5.N when typed-struct fields are removed.
5303        let views = caps.views();
5304        assert_eq!(
5305            views.hardware().gpu_vendor(),
5306            Some(super::GpuVendor::Nvidia),
5307            "vendor lost in conversion"
5308        );
5309        assert_eq!(views.hardware().memory_gb, 64);
5310        assert_eq!(views.hardware().total_vram_gb(), 80);
5311        assert!(caps.has_tag("gpu"));
5312    }
5313
5314    /// Regression: BUG_REPORT.md #15 — `collect_payloads` previously
5315    /// dereferenced every per-entry pointer without a null check, so a C
5316    /// caller passing an array containing a null entry produced UB on
5317    /// `from_raw_parts(null, len)`. The fix returns `None` for any null
5318    /// pointer with non-zero length so the caller can return
5319    /// `NetError::NullPointer`. A null pointer with length 0 is treated
5320    /// as an empty payload (allowed because the pointer is never
5321    /// dereferenced).
5322    #[test]
5323    fn collect_payloads_rejects_null_entry_with_nonzero_length() {
5324        let buf_a = b"hello".as_slice();
5325        let buf_b = b"world".as_slice();
5326        let ptrs: [*const u8; 3] = [buf_a.as_ptr(), std::ptr::null(), buf_b.as_ptr()];
5327        let lens: [usize; 3] = [buf_a.len(), 4, buf_b.len()];
5328
5329        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 3) };
5330        assert!(
5331            result.is_none(),
5332            "null entry with non-zero length must reject the whole batch"
5333        );
5334    }
5335
5336    #[test]
5337    fn collect_payloads_allows_null_entry_with_zero_length() {
5338        let buf_a = b"hello".as_slice();
5339        let ptrs: [*const u8; 2] = [buf_a.as_ptr(), std::ptr::null()];
5340        let lens: [usize; 2] = [buf_a.len(), 0];
5341
5342        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 2) }
5343            .expect("zero-length null is treated as empty payload");
5344        assert_eq!(result.len(), 2);
5345        assert_eq!(&result[0][..], b"hello");
5346        assert!(result[1].is_empty());
5347    }
5348
5349    #[test]
5350    fn collect_payloads_happy_path() {
5351        let buf_a = b"abc".as_slice();
5352        let buf_b = b"defg".as_slice();
5353        let ptrs: [*const u8; 2] = [buf_a.as_ptr(), buf_b.as_ptr()];
5354        let lens: [usize; 2] = [buf_a.len(), buf_b.len()];
5355
5356        let result = unsafe { collect_payloads(ptrs.as_ptr(), lens.as_ptr(), 2) }
5357            .expect("non-null entries should succeed");
5358        assert_eq!(result.len(), 2);
5359        assert_eq!(&result[0][..], b"abc");
5360        assert_eq!(&result[1][..], b"defg");
5361    }
5362}
5363
5364#[cfg(all(test, feature = "net"))]
5365mod subnet_authority_config_tests {
5366    //! Base `libnet`'s JSON constructor accepts subnet TRUST ANCHORS
5367    //! (review-10 P1-7).
5368    //!
5369    //! Go and C both receive their node from this constructor. Before the
5370    //! conversion moved into the core they could not declare an authority,
5371    //! a security attachment, or a control channel at all — so their
5372    //! advertised provider verb could never produce an authorized
5373    //! subnet-exported service, however correct the rest of the binding
5374    //! was. These tests pin that the fields parse, that the SAME
5375    //! validation every other SDK runs applies here, and that a
5376    //! configuration mistake is refused rather than silently dropped.
5377
5378    use super::*;
5379
5380    const AUTHORITY: &str = "d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7";
5381    const ROOT: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
5382
5383    fn parse(json: &str) -> Result<MeshNewConfig, serde_json::Error> {
5384        serde_json::from_str(json)
5385    }
5386
5387    /// The three authority fields deserialize into the core DTOs, and a
5388    /// well-formed set converts.
5389    #[test]
5390    fn trust_anchor_fields_parse_and_convert() {
5391        let cfg = parse(&format!(
5392            r#"{{"bind_addr":"127.0.0.1:0","psk_hex":"{psk}",
5393                 "subnet_authorities":[{{"authority_hex":"{AUTHORITY}",
5394                     "root_hexes":["{ROOT}"],"maximum_grant_lifetime_secs":604800}}],
5395                 "subnet_attachment":[3,9],
5396                 "subnet_control_channel":"subnet.control"}}"#,
5397            psk = "42".repeat(32),
5398        ))
5399        .expect("config parses");
5400
5401        let authorities = cfg.subnet_authorities.expect("authorities present");
5402        assert_eq!(authorities.len(), 1);
5403        let core = authorities[0].to_core().expect("converts");
5404        assert_eq!(core.maximum_grant_lifetime_secs, 604_800);
5405        assert_eq!(core.roots.len(), 1);
5406        assert!(
5407            crate::adapter::net::subnet::provision::validate_subnet_authorities(&[core]).is_ok(),
5408            "a well-formed anchor must validate",
5409        );
5410
5411        assert_eq!(cfg.subnet_attachment.as_deref(), Some(&[3u8, 9][..]));
5412        assert_eq!(
5413            cfg.subnet_control_channel.as_deref(),
5414            Some("subnet.control")
5415        );
5416    }
5417
5418    /// Omitting them is the ordinary case and must stay valid — an
5419    /// unconfigured node simply fails every protected subnet assertion
5420    /// closed rather than refusing to start.
5421    #[test]
5422    fn trust_anchor_fields_are_optional() {
5423        let cfg = parse(&format!(
5424            r#"{{"bind_addr":"127.0.0.1:0","psk_hex":"{}"}}"#,
5425            "42".repeat(32)
5426        ))
5427        .expect("config parses without any subnet authority field");
5428        assert!(cfg.subnet_authorities.is_none());
5429        assert!(cfg.subnet_attachment.is_none());
5430        assert!(cfg.subnet_control_channel.is_none());
5431    }
5432
5433    /// The SAME validation every other SDK runs applies here. Each of
5434    /// these is a configuration mistake the constructor must refuse, not
5435    /// a runtime verification outcome — and refusing means
5436    /// `NET_ERR_MESH_INIT`, never a node that came up trusting nothing.
5437    #[test]
5438    fn configuration_mistakes_are_refused() {
5439        use crate::adapter::net::subnet::provision::{
5440            dto::SubnetAuthorityConfigDto, validate_subnet_authorities,
5441        };
5442
5443        let good = SubnetAuthorityConfigDto {
5444            authority_hex: AUTHORITY.to_string(),
5445            root_hexes: vec![ROOT.to_string()],
5446            maximum_grant_lifetime_secs: 604_800,
5447        };
5448
5449        // Malformed hex never reaches validation — the DTO refuses it.
5450        let bad_hex = SubnetAuthorityConfigDto {
5451            authority_hex: "not-hex".to_string(),
5452            ..good.clone()
5453        };
5454        assert!(bad_hex.to_core().is_err(), "a malformed id must be refused");
5455
5456        // Empty root set: would fail closed forever.
5457        let empty_roots = SubnetAuthorityConfigDto {
5458            root_hexes: Vec::new(),
5459            ..good.clone()
5460        };
5461        assert!(validate_subnet_authorities(&[empty_roots.to_core().expect("converts")]).is_err());
5462
5463        // Zero lifetime.
5464        let zero_life = SubnetAuthorityConfigDto {
5465            maximum_grant_lifetime_secs: 0,
5466            ..good.clone()
5467        };
5468        assert!(validate_subnet_authorities(&[zero_life.to_core().expect("converts")]).is_err());
5469
5470        // Duplicate authority.
5471        let one = good.to_core().expect("converts");
5472        let two = good.to_core().expect("converts");
5473        assert!(validate_subnet_authorities(&[one, two]).is_err());
5474    }
5475
5476    /// The EXACT JSON the Go binding emits deserializes here.
5477    ///
5478    /// Go's `[]uint8` is `[]byte`, and `encoding/json` special-cases that
5479    /// as BASE64 on the way out — so `SubnetAttachment []uint8` reached
5480    /// this constructor as `"Awk="` instead of `[3,9]` and the whole
5481    /// config was refused as invalid JSON. The asymmetry is what made it
5482    /// easy to ship: unmarshalling `[3,9]` INTO `[]uint8` succeeds, so the
5483    /// manifest parsed and only the constructor failed.
5484    ///
5485    /// This is a captured sample of `json.Marshal(MeshConfig{...})` after
5486    /// the fix, so a future Go type change that reintroduces base64 (or
5487    /// renames a field) fails HERE rather than in a cgo test that cannot
5488    /// build on every host.
5489    #[test]
5490    fn the_go_bindings_emitted_config_deserializes() {
5491        const GO_EMITTED: &str = r#"{"bind_addr":"127.0.0.1:0","psk_hex":"4242424242424242424242424242424242424242424242424242424242424242","subnet_exports":[{"name":"factory-export","access":"granted","binding":{"subnet":{"authority_hex":"d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7","path":{"levels":[3,9]}},"topology_epoch":0}}],"subnet_authorities":[{"authority_hex":"d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7","root_hexes":["d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7"],"maximum_grant_lifetime_secs":604800}],"subnet_attachment":[3]}"#;
5492
5493        let cfg = parse(GO_EMITTED).expect("the Go binding's own JSON must deserialize");
5494        assert_eq!(cfg.subnet_attachment.as_deref(), Some(&[3u8][..]));
5495        let exports = cfg.subnet_exports.expect("exports present");
5496        assert_eq!(exports.len(), 1);
5497        let export = exports[0].to_core().expect("export converts");
5498        assert_eq!(export.name, "factory-export");
5499        let authorities = cfg.subnet_authorities.expect("authorities present");
5500        assert!(authorities[0].to_core().is_ok());
5501    }
5502
5503    /// The base64 shape a `[]uint8` field WOULD produce is refused, so the
5504    /// regression above cannot pass by the deserializer being lenient.
5505    #[test]
5506    fn a_base64_level_array_is_refused() {
5507        let base64_attachment =
5508            r#"{"bind_addr":"127.0.0.1:0","psk_hex":"42","subnet_attachment":"Awk="}"#;
5509        assert!(
5510            parse(base64_attachment).is_err(),
5511            "a base64 attachment must be refused, not silently accepted",
5512        );
5513    }
5514
5515    /// A path deeper than the four-level hierarchy is refused rather
5516    /// than truncated.
5517    #[test]
5518    fn an_over_deep_attachment_is_refused() {
5519        use crate::adapter::net::subnet::provision::dto::SubnetPathDto;
5520        assert!(SubnetPathDto {
5521            levels: vec![1, 2, 3, 4, 5]
5522        }
5523        .to_core()
5524        .is_err());
5525        assert!(SubnetPathDto {
5526            levels: vec![1, 2, 3, 4]
5527        }
5528        .to_core()
5529        .is_ok());
5530        assert!(SubnetPathDto { levels: vec![] }.to_core().is_ok());
5531    }
5532}
5533
5534#[cfg(all(test, feature = "net"))]
5535mod named_export_construction_tests {
5536    //! The NAMED EXPORT map is Rust-owned and frozen at construction
5537    //! (review-10 P1-6).
5538    //!
5539    //! It lives on the node rather than in each language wrapper so that
5540    //! name→binding resolution happens in one place for every boundary —
5541    //! including the C ABI, which has no wrapper object to hold a map. A
5542    //! node must never come up holding an ambiguous map.
5543
5544    use super::*;
5545    use crate::adapter::net::identity::EntityKeypair;
5546    use crate::adapter::net::subnet::provision::{NamedSubnetExport, SubnetExportAccess};
5547    use crate::adapter::net::subnet::{SubnetRef, TopologySubnetId};
5548
5549    fn export(name: &str) -> NamedSubnetExport {
5550        NamedSubnetExport {
5551            name: name.to_string(),
5552            access: SubnetExportAccess::Granted,
5553            subnet: SubnetRef {
5554                authority: EntityKeypair::from_bytes([0x11; 32]).entity_id().clone(),
5555                path: TopologySubnetId::new(&[3, 9]),
5556            },
5557            topology_epoch: 0,
5558        }
5559    }
5560
5561    async fn build(exports: Vec<NamedSubnetExport>) -> Result<MeshNode, AdapterError> {
5562        let mut cfg = MeshNodeConfig::new("127.0.0.1:0".parse().expect("addr"), [0u8; 32]);
5563        for e in exports {
5564            cfg = cfg.with_subnet_export(e);
5565        }
5566        MeshNode::new(EntityKeypair::generate(), cfg).await
5567    }
5568
5569    /// A configured map is frozen on the node and resolves by name.
5570    #[tokio::test]
5571    async fn configured_exports_are_resolvable_from_the_node() {
5572        let node = build(vec![export("factory-export"), export("lab-export")])
5573            .await
5574            .expect("distinct names construct");
5575        let map = node.subnet_exports();
5576        assert!(map.resolve("factory-export").is_some());
5577        assert!(map.resolve("lab-export").is_some());
5578        assert!(
5579            map.resolve("no-such-export").is_none(),
5580            "an unconfigured name must not resolve",
5581        );
5582    }
5583
5584    /// A duplicate label is a configuration mistake: the node REFUSES to
5585    /// come up rather than silently keeping one of the two.
5586    #[tokio::test]
5587    async fn a_duplicate_export_name_refuses_construction() {
5588        let Err(err) = build(vec![export("dup"), export("dup")]).await else {
5589            panic!("a duplicate label must refuse construction");
5590        };
5591        assert!(
5592            err.to_string().contains("duplicate_export_name"),
5593            "expected the stable kind in the refusal, got {err}",
5594        );
5595    }
5596
5597    /// So is an empty one.
5598    #[tokio::test]
5599    async fn an_empty_export_name_refuses_construction() {
5600        let Err(err) = build(vec![export("")]).await else {
5601            panic!("an empty label must refuse construction");
5602        };
5603        assert!(
5604            err.to_string().contains("empty_export_name"),
5605            "expected the stable kind in the refusal, got {err}",
5606        );
5607    }
5608
5609    /// No exports is the ordinary case and must stay valid — the map is
5610    /// simply empty, and every serve against a name fails to resolve.
5611    #[tokio::test]
5612    async fn no_exports_is_valid_and_resolves_nothing() {
5613        let node = build(Vec::new()).await.expect("no exports constructs");
5614        assert!(node.subnet_exports().is_empty());
5615        assert!(node.subnet_exports().resolve("anything").is_none());
5616    }
5617}