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