Skip to main content

net/ffi/
blob.rs

1//! C FFI for Dataforts Phase 3 blob storage.
2//!
3//! Exposes:
4//!
5//! - `net_blob_register_fs_adapter` / `net_blob_unregister_adapter` —
6//!   registry lifecycle for a Rust-backed FileSystemAdapter.
7//! - `net_blob_adapter_registered` — probe.
8//! - `net_blob_publish` — content → encoded BlobRef bytes (caller
9//!   frees).
10//! - `net_blob_resolve` — payload bytes → resolved content (caller
11//!   frees).
12//!
13//! Returned buffers are heap-owned by Rust and MUST be freed via
14//! `net_blob_free_buffer`. Errors use the same `c_int` discipline
15//! as the rest of the FFI surface; the blob-specific extended
16//! codes are in the `-110..` range to stay below the cortex
17//! surface's `-100..-109` band.
18//!
19//! # Safety
20//!
21//! Every entry point is `unsafe extern "C"` and inherits the same
22//! caller-side contract as the rest of the FFI surface (see
23//! `ffi/mod.rs` and `include/net.h`): valid + aligned pointers,
24//! opaque handles produced by this crate's matching constructor
25//! (`Box::into_raw` inside the FFI surface — foreign-allocated
26//! pointers will UB when consumed by `Box::from_raw`),
27//! NUL-terminated UTF-8 strings, accurate buffer/length pairs,
28//! out-parameter pointers writable for the call's lifetime, and
29//! Rust-allocated buffers freed via `net_blob_free_buffer`.
30#![allow(clippy::missing_safety_doc)]
31#![expect(
32    clippy::undocumented_unsafe_blocks,
33    reason = "module-wide FFI safety contract documented in the # Safety preamble above"
34)]
35#![expect(
36    clippy::multiple_unsafe_ops_per_block,
37    reason = "FFI entry points routinely deref + write to multiple out-parameter fields under the same caller contract"
38)]
39
40use std::ffi::{c_char, c_int, CStr};
41use std::os::raw::c_void;
42use std::path::PathBuf;
43use std::ptr;
44use std::sync::Arc;
45
46use tokio::runtime::Runtime;
47
48#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
49use crate::adapter::net::behavior::TopologyScope;
50use crate::adapter::net::dataforts::{
51    global_blob_adapter_registry, publish_blob, resolve_payload, BlobAdapter,
52    BlobError as InnerBlobError, FileSystemAdapter,
53};
54// `InnerBlobRef` is only decoded inside the `MeshBlobAdapter`
55// store/fetch/exists entry points, which themselves require the
56// `dataforts + netdb + redex-disk` triple. Without the triple,
57// the import is unused and `-D warnings` fails CI.
58#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
59use crate::adapter::net::dataforts::{
60    BlobRef as InnerBlobRef, MeshBlobAdapter as InnerMeshBlobAdapter,
61    OverflowConfig as InnerOverflowConfig,
62};
63
64use super::NetError;
65
66/// BlobRef decode failed (truncated / unsupported version).
67pub const NET_ERR_BLOB_DECODE: c_int = -110;
68/// Adapter registry: adapter id already registered.
69pub const NET_ERR_BLOB_DUPLICATE_ID: c_int = -111;
70/// Adapter registry: adapter id not found.
71pub const NET_ERR_BLOB_NOT_REGISTERED: c_int = -112;
72/// Adapter returned `NotFound` for the requested URI.
73pub const NET_ERR_BLOB_NOT_FOUND: c_int = -113;
74/// Substrate-side hash verification rejected the fetched bytes.
75pub const NET_ERR_BLOB_HASH_MISMATCH: c_int = -114;
76/// Adapter returned a non-classifiable backend error.
77pub const NET_ERR_BLOB_BACKEND: c_int = -115;
78/// `BlobRef::UnsupportedScheme` — used for both "unknown URI scheme"
79/// and "channel pointing at an unregistered adapter id".
80pub const NET_ERR_BLOB_UNSUPPORTED_SCHEME: c_int = -116;
81/// Channel has no `blob_adapter_id` configured.
82pub const NET_ERR_BLOB_ADAPTER_NOT_CONFIGURED: c_int = -118;
83/// Configured `blob_adapter_id` is not in the registry.
84pub const NET_ERR_BLOB_ADAPTER_NOT_REGISTERED: c_int = -119;
85/// Panic surfaced from inside a user-installed adapter callback
86/// (or anywhere on the FFI body). The substrate catches it with
87/// `catch_unwind` and reports this code rather than unwinding
88/// across the FFI boundary (which is undefined behaviour for the
89/// C / cgo / Python callers).
90pub const NET_ERR_BLOB_PANIC: c_int = -117;
91/// Auth gate rejected the blob op: AuthGuard ACL miss, or no
92/// guard configured for an op that requires one. Distinct from
93/// `NET_ERR_BLOB_BACKEND` so bindings can route 401-style hits
94/// without parsing the error string.
95pub const NET_ERR_BLOB_UNAUTHORIZED: c_int = -120;
96
97fn runtime() -> &'static Arc<Runtime> {
98    use std::sync::OnceLock;
99    static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
100    RT.get_or_init(|| {
101        match tokio::runtime::Builder::new_multi_thread()
102            .enable_all()
103            .build()
104        {
105            Ok(rt) => Arc::new(rt),
106            Err(e) => {
107                eprintln!("FATAL: blob FFI tokio runtime build failure ({e:?}); aborting");
108                std::process::abort();
109            }
110        }
111    })
112}
113
114fn block_on<F: std::future::Future>(future: F) -> F::Output {
115    if tokio::runtime::Handle::try_current().is_ok() {
116        eprintln!("FATAL: blob FFI called from inside a tokio runtime context; aborting");
117        std::process::abort();
118    }
119    runtime().block_on(future)
120}
121
122unsafe fn c_str_to_owned(p: *const c_char) -> Option<String> {
123    if p.is_null() {
124        return None;
125    }
126    CStr::from_ptr(p).to_str().ok().map(|s| s.to_owned())
127}
128
129fn err_to_code(e: &InnerBlobError) -> c_int {
130    match e {
131        InnerBlobError::HashMismatch { .. } => NET_ERR_BLOB_HASH_MISMATCH,
132        InnerBlobError::NotFound(_) => NET_ERR_BLOB_NOT_FOUND,
133        InnerBlobError::Backend(_) => NET_ERR_BLOB_BACKEND,
134        InnerBlobError::Cancelled => NET_ERR_BLOB_BACKEND,
135        InnerBlobError::UnsupportedScheme(_) => NET_ERR_BLOB_UNSUPPORTED_SCHEME,
136        InnerBlobError::UnsupportedVersion(_) => NET_ERR_BLOB_DECODE,
137        InnerBlobError::Decode(_) => NET_ERR_BLOB_DECODE,
138        InnerBlobError::AdapterNotConfigured => NET_ERR_BLOB_ADAPTER_NOT_CONFIGURED,
139        InnerBlobError::AdapterNotRegistered(_) => NET_ERR_BLOB_ADAPTER_NOT_REGISTERED,
140        InnerBlobError::Unauthorized(_) => NET_ERR_BLOB_UNAUTHORIZED,
141        // `ShortChunk` is a size disagreement (backend truncated
142        // the chunk); route through `NET_ERR_BLOB_BACKEND` rather
143        // than `NET_ERR_BLOB_HASH_MISMATCH` so retry logic that
144        // distinguishes truncation from content divergence keeps
145        // the existing classifier intact. A dedicated code can be
146        // added later when a binding consumer needs to fork on the
147        // distinction at the FFI surface.
148        InnerBlobError::ShortChunk { .. } => NET_ERR_BLOB_BACKEND,
149    }
150}
151
152/// Register a filesystem-backed BlobAdapter under `adapter_id`.
153/// Both `adapter_id` and `root` are null-terminated UTF-8 strings.
154/// Returns `0` on success, `NET_ERR_BLOB_DUPLICATE_ID` if the id
155/// already exists, or `NetError::InvalidUtf8` / `NullPointer` for
156/// malformed input.
157///
158/// # Safety
159/// `adapter_id` and `root` must each point to a valid null-terminated
160/// UTF-8 byte sequence and remain valid for the duration of this
161/// call. Either may be null, in which case the function returns
162/// `NetError::InvalidUtf8`.
163#[unsafe(no_mangle)]
164pub unsafe extern "C" fn net_blob_register_fs_adapter(
165    adapter_id: *const c_char,
166    root: *const c_char,
167) -> c_int {
168    let id = match c_str_to_owned(adapter_id) {
169        Some(s) => s,
170        None => return NetError::InvalidUtf8.into(),
171    };
172    let root = match c_str_to_owned(root) {
173        Some(s) => s,
174        None => return NetError::InvalidUtf8.into(),
175    };
176    let adapter: Arc<dyn BlobAdapter> =
177        Arc::new(FileSystemAdapter::new(id.clone(), PathBuf::from(root)));
178    match global_blob_adapter_registry().register(adapter) {
179        Ok(()) => 0,
180        Err(_) => NET_ERR_BLOB_DUPLICATE_ID,
181    }
182}
183
184/// Remove an adapter registration. Returns `1` if an adapter was
185/// removed, `0` if no adapter was registered under that id.
186///
187/// # Safety
188/// `adapter_id` must point to a valid null-terminated UTF-8 byte
189/// sequence and remain valid for the call. Null returns
190/// `NetError::InvalidUtf8`.
191#[unsafe(no_mangle)]
192pub unsafe extern "C" fn net_blob_unregister_adapter(adapter_id: *const c_char) -> c_int {
193    let id = match c_str_to_owned(adapter_id) {
194        Some(s) => s,
195        None => return NetError::InvalidUtf8.into(),
196    };
197    if global_blob_adapter_registry().unregister(&id).is_some() {
198        1
199    } else {
200        0
201    }
202}
203
204/// Returns `1` if `adapter_id` resolves to a registered adapter,
205/// `0` otherwise.
206///
207/// # Safety
208/// `adapter_id` must point to a valid null-terminated UTF-8 byte
209/// sequence and remain valid for the call.
210#[unsafe(no_mangle)]
211pub unsafe extern "C" fn net_blob_adapter_registered(adapter_id: *const c_char) -> c_int {
212    let id = match c_str_to_owned(adapter_id) {
213        Some(s) => s,
214        None => return NetError::InvalidUtf8.into(),
215    };
216    if global_blob_adapter_registry().get(&id).is_some() {
217        1
218    } else {
219        0
220    }
221}
222
223/// Publish `data` (len `data_len` bytes) to the adapter registered
224/// under `adapter_id`. On success returns `0` and writes a freshly-
225/// allocated Rust-owned buffer pointer into `*out_payload` /
226/// `*out_payload_len` containing the wire-encoded BlobRef. Caller
227/// MUST free via [`net_blob_free_buffer`].
228///
229/// On error returns a negative code and leaves the out-params at
230/// `(null, 0)`.
231///
232/// # Safety
233/// - `adapter_id` and `uri` must each point to a valid null-
234///   terminated UTF-8 byte sequence.
235/// - `data` must point to a readable region of at least `data_len`
236///   bytes (or be null when `data_len == 0`).
237/// - `out_payload` and `out_payload_len` must each point to writable
238///   `*mut u8` / `usize` storage; the function writes through both.
239#[unsafe(no_mangle)]
240pub unsafe extern "C" fn net_blob_publish(
241    adapter_id: *const c_char,
242    uri: *const c_char,
243    data: *const u8,
244    data_len: usize,
245    out_payload: *mut *mut u8,
246    out_payload_len: *mut usize,
247) -> c_int {
248    if out_payload.is_null() || out_payload_len.is_null() {
249        return NetError::NullPointer.into();
250    }
251    *out_payload = ptr::null_mut();
252    *out_payload_len = 0;
253
254    let id = match c_str_to_owned(adapter_id) {
255        Some(s) => s,
256        None => return NetError::InvalidUtf8.into(),
257    };
258    let uri = match c_str_to_owned(uri) {
259        Some(s) => s,
260        None => return NetError::InvalidUtf8.into(),
261    };
262    if data.is_null() && data_len > 0 {
263        return NetError::NullPointer.into();
264    }
265    // `slice::from_raw_parts` requires `len <= isize::MAX`.
266    if data_len > isize::MAX as usize {
267        return NetError::InvalidJson.into();
268    }
269    let data_slice = if data_len == 0 {
270        &[][..]
271    } else {
272        std::slice::from_raw_parts(data, data_len)
273    };
274
275    let adapter = match global_blob_adapter_registry().get(&id) {
276        Some(a) => a,
277        None => return NET_ERR_BLOB_NOT_REGISTERED,
278    };
279    // Wrap the body in catch_unwind so a panic in a user-
280    // installed adapter callback (or anywhere downstream) cannot
281    // unwind across the FFI boundary into the C / cgo / Python
282    // caller — that's undefined behaviour.
283    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
284        block_on(async move { publish_blob(adapter.as_ref(), uri, data_slice).await })
285    }));
286    let bytes = match result {
287        Ok(Ok(b)) => b,
288        Ok(Err(e)) => return err_to_code(&e),
289        Err(_) => return NET_ERR_BLOB_PANIC,
290    };
291
292    write_bytes_out(&bytes, out_payload, out_payload_len)
293}
294
295/// Resolve a payload to its content bytes. Inline payloads round-
296/// trip; encoded-BlobRef payloads fetch + verify through the
297/// adapter registered under `adapter_id`.
298///
299/// Returns `0` and writes a freshly-allocated Rust-owned buffer
300/// into `*out_content` / `*out_content_len`. Caller MUST free via
301/// [`net_blob_free_buffer`]. On error returns a negative code and
302/// leaves the out-params at `(null, 0)`.
303///
304/// # Safety
305/// - `adapter_id` must point to a valid null-terminated UTF-8 byte
306///   sequence.
307/// - `payload` must point to a readable region of at least
308///   `payload_len` bytes (or be null when `payload_len == 0`).
309/// - `out_content` and `out_content_len` must each point to writable
310///   `*mut u8` / `usize` storage.
311#[unsafe(no_mangle)]
312pub unsafe extern "C" fn net_blob_resolve(
313    adapter_id: *const c_char,
314    payload: *const u8,
315    payload_len: usize,
316    out_content: *mut *mut u8,
317    out_content_len: *mut usize,
318) -> c_int {
319    if out_content.is_null() || out_content_len.is_null() {
320        return NetError::NullPointer.into();
321    }
322    *out_content = ptr::null_mut();
323    *out_content_len = 0;
324
325    let id = match c_str_to_owned(adapter_id) {
326        Some(s) => s,
327        None => return NetError::InvalidUtf8.into(),
328    };
329    if payload.is_null() && payload_len > 0 {
330        return NetError::NullPointer.into();
331    }
332    // `slice::from_raw_parts` requires `len <= isize::MAX`.
333    if payload_len > isize::MAX as usize {
334        return NetError::InvalidJson.into();
335    }
336    let payload_slice = if payload_len == 0 {
337        &[][..]
338    } else {
339        std::slice::from_raw_parts(payload, payload_len)
340    };
341
342    let adapter = match global_blob_adapter_registry().get(&id) {
343        Some(a) => a,
344        None => return NET_ERR_BLOB_NOT_REGISTERED,
345    };
346    // Same catch_unwind protection as net_blob_publish.
347    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
348        block_on(async move { resolve_payload(payload_slice, adapter.as_ref()).await })
349    }));
350    let bytes = match result {
351        Ok(Ok(b)) => b,
352        Ok(Err(e)) => return err_to_code(&e),
353        Err(_) => return NET_ERR_BLOB_PANIC,
354    };
355
356    write_bytes_out(&bytes, out_content, out_content_len)
357}
358
359/// Allocate a Rust-owned buffer with an explicit `Layout::array::<u8>(len)`,
360/// copy `src` into it, and write `(ptr, len)` to the caller's out-pointers.
361/// Pairs with [`net_blob_free_buffer`], which deallocates with the matching
362/// layout. Pre-fix this path went `Vec → into_boxed_slice → Box::into_raw`,
363/// freed via `Box::from_raw(slice_from_raw_parts_mut(ptr, len))`. That
364/// worked because `into_boxed_slice` happens to shrink-to-fit today, but
365/// relied on a `Vec` / `Box<[u8]>` allocator-internals coincidence. A
366/// future refactor to `Vec::leak` (which does NOT shrink) would have
367/// silently mismatched the dealloc layout. Using an explicit
368/// `Layout::array::<u8>` on both sides makes the contract self-evident.
369///
370/// # Safety
371/// `out_ptr` and `out_len` must be writable.
372unsafe fn write_bytes_out(src: &[u8], out_ptr: *mut *mut u8, out_len: *mut usize) -> c_int {
373    let len = src.len();
374    if len == 0 {
375        unsafe {
376            *out_ptr = ptr::null_mut();
377            *out_len = 0;
378        }
379        return 0;
380    }
381    let layout = match std::alloc::Layout::array::<u8>(len) {
382        Ok(l) => l,
383        // `Layout::array::<u8>` only fails when `len > isize::MAX`.
384        // The publish/resolve paths have already rejected that range
385        // (slice::from_raw_parts shares the same cap), so this is
386        // unreachable from the existing call sites — but defending it
387        // here keeps `write_bytes_out` safe to reuse from any future
388        // caller. Returning a typed code beats panicking across the
389        // surrounding `extern "C"` frame.
390        Err(_) => return NetError::InvalidJson.into(),
391    };
392    let alloc_ptr = unsafe { std::alloc::alloc(layout) };
393    if alloc_ptr.is_null() {
394        std::alloc::handle_alloc_error(layout);
395    }
396    unsafe {
397        std::ptr::copy_nonoverlapping(src.as_ptr(), alloc_ptr, len);
398        *out_ptr = alloc_ptr;
399        *out_len = len;
400    }
401    0
402}
403
404/// Free a buffer returned by [`net_blob_publish`] or
405/// [`net_blob_resolve`]. Calling with `(null, _)` or `(_, 0)` is a no-op.
406///
407/// # Safety
408/// `ptr` MUST be a buffer that the substrate previously returned
409/// from `net_blob_publish` or `net_blob_resolve` (or null), and
410/// `len` MUST match the corresponding `*out_*_len` value from
411/// that call. Calling with any other `(ptr, len)` is undefined
412/// behaviour.
413#[unsafe(no_mangle)]
414pub unsafe extern "C" fn net_blob_free_buffer(ptr: *mut u8, len: usize) {
415    if ptr.is_null() || len == 0 {
416        return;
417    }
418    // Match the `Layout::array::<u8>(len)` used by `write_bytes_out`.
419    // Any `len > isize::MAX` could not have come from us — the
420    // allocating side would have rejected the same layout — so the
421    // safest response is to abandon the free rather than unwind
422    // across the FFI boundary.
423    let layout = match std::alloc::Layout::array::<u8>(len) {
424        Ok(l) => l,
425        Err(_) => return,
426    };
427    std::alloc::dealloc(ptr, layout);
428}
429
430// Ensure the unused-import lint stays quiet under feature gates that
431// drop one of these surfaces — currently all callable.
432#[allow(dead_code)]
433fn _force_use() -> *mut c_void {
434    ptr::null_mut()
435}
436
437// =========================================================================
438// C-side callback adapter — register a function-pointer-table from
439// a cgo / native caller and let the substrate dispatch BlobAdapter
440// calls into it. The substrate wraps the table as a `dyn BlobAdapter`
441// and stores it in the global registry under the supplied id.
442// =========================================================================
443
444use std::ops::Range;
445
446use async_trait::async_trait;
447use bytes::Bytes;
448
449/// `store` function pointer. Caller-allocates nothing; returns
450/// `0` on success or a negative `c_int` on failure.
451pub type NetBlobAdapterStoreFn = unsafe extern "C" fn(
452    ctx: *mut c_void,
453    uri: *const c_char,
454    hash: *const u8, // exactly 32 bytes
455    size: u64,
456    data: *const u8,
457    data_len: usize,
458) -> c_int;
459
460/// `fetch` / `fetch_range` function pointer. Caller-allocates the
461/// return buffer and writes the pointer + length into the
462/// out-params. The substrate releases it via the vtable's
463/// `free_buffer` after consuming the bytes.
464pub type NetBlobAdapterFetchFn = unsafe extern "C" fn(
465    ctx: *mut c_void,
466    uri: *const c_char,
467    hash: *const u8,
468    size: u64,
469    out_data: *mut *mut u8,
470    out_len: *mut usize,
471) -> c_int;
472
473/// `fetch_range` function pointer.
474pub type NetBlobAdapterFetchRangeFn = unsafe extern "C" fn(
475    ctx: *mut c_void,
476    uri: *const c_char,
477    hash: *const u8,
478    size: u64,
479    range_start: u64,
480    range_end: u64,
481    out_data: *mut *mut u8,
482    out_len: *mut usize,
483) -> c_int;
484
485/// `exists` function pointer. Writes a `0` / `1` boolean into
486/// `out_exists` on success.
487pub type NetBlobAdapterExistsFn = unsafe extern "C" fn(
488    ctx: *mut c_void,
489    uri: *const c_char,
490    hash: *const u8,
491    size: u64,
492    out_exists: *mut c_int,
493) -> c_int;
494
495/// Frees a buffer that the caller's `fetch` / `fetch_range`
496/// allocated. The substrate calls this after consuming the
497/// returned bytes.
498pub type NetBlobAdapterFreeFn = unsafe extern "C" fn(ctx: *mut c_void, data: *mut u8, len: usize);
499
500/// Function-pointer-table the C-side caller passes to
501/// [`net_blob_register_callback_adapter`]. The struct is `#[repr(C)]`
502/// for cross-ABI stability.
503#[repr(C)]
504#[derive(Clone, Copy)]
505pub struct NetBlobAdapterVtable {
506    /// `store(ctx, uri, hash, size, data, data_len) -> c_int`
507    pub store: NetBlobAdapterStoreFn,
508    /// `fetch(ctx, uri, hash, size, &out_data, &out_len) -> c_int`
509    pub fetch: NetBlobAdapterFetchFn,
510    /// `fetch_range(ctx, uri, hash, size, start, end, &out_data, &out_len)`
511    pub fetch_range: NetBlobAdapterFetchRangeFn,
512    /// `exists(ctx, uri, hash, size, &out_exists) -> c_int`
513    pub exists: NetBlobAdapterExistsFn,
514    /// `free_buffer(ctx, data, len)` — substrate calls this after
515    /// consuming a buffer the caller returned via `fetch` /
516    /// `fetch_range`.
517    pub free_buffer: NetBlobAdapterFreeFn,
518}
519
520/// Opaque caller-context pointer.
521///
522/// # Concurrency contract (caller MUST uphold)
523///
524/// The substrate dispatches every vtable call from a
525/// `tokio::task::spawn_blocking` worker, which means the same
526/// `ctx` pointer is observed from **multiple OS threads over the
527/// lifetime of the registration** and may be observed
528/// **concurrently** if two events for the same adapter are
529/// in-flight. `Send + Sync` are asserted unconditionally because
530/// the substrate has no visibility into what the pointer
531/// references — the C-side registrant is the trust boundary.
532///
533/// In practical terms, this means a registrant **MUST** pass a
534/// `ctx` whose pointee is:
535///
536/// - **`Send` across threads**: any per-thread state (e.g. a
537///   thread-local OS handle, a goroutine-local pointer, a
538///   Python `PyObject*` held without the GIL) is unsafe.
539/// - **`Sync` for concurrent dispatch**: any state mutated
540///   inside vtable callbacks must be protected against
541///   data races by the registrant (lock, atomic, etc.).
542///
543/// Wrappers that cannot meet the `Sync` requirement (e.g. a
544/// Python adapter that uses the GIL) MUST serialize their own
545/// dispatch behind a `Mutex` before passing control to the
546/// language runtime.
547struct OpaqueCtx(*mut c_void);
548
549// SAFETY: opaque-pointer transport — see `OpaqueCtx` doc above.
550// Cross-thread coherence of the pointee is the C-side caller's
551// responsibility; the substrate only reads and forwards the
552// same address verbatim.
553unsafe impl Send for OpaqueCtx {}
554unsafe impl Sync for OpaqueCtx {}
555
556impl OpaqueCtx {
557    fn new(ptr: *mut c_void) -> Self {
558        Self(ptr)
559    }
560    fn get(&self) -> *mut c_void {
561        self.0
562    }
563}
564
565/// `BlobAdapter` impl that calls into a vtable of C function
566/// pointers. Each trait method translates the args into
567/// `*const c_char` / `*const u8` shapes, dispatches inside
568/// `tokio::task::spawn_blocking` so the tokio worker isn't
569/// blocked on synchronous C-side I/O, and maps the return code
570/// back into a `Result<_, BlobError>`.
571struct CallbackBlobAdapter {
572    id: String,
573    vtable: NetBlobAdapterVtable,
574    ctx: Arc<OpaqueCtx>,
575}
576
577unsafe impl Send for CallbackBlobAdapter {}
578unsafe impl Sync for CallbackBlobAdapter {}
579
580fn code_to_err(code: c_int, label: &str) -> InnerBlobError {
581    match code {
582        NET_ERR_BLOB_NOT_FOUND => InnerBlobError::NotFound(label.into()),
583        NET_ERR_BLOB_HASH_MISMATCH => InnerBlobError::Backend(format!(
584            "{}: substrate hash mismatch (caller returned wrong bytes)",
585            label
586        )),
587        NET_ERR_BLOB_UNSUPPORTED_SCHEME => InnerBlobError::UnsupportedScheme(label.into()),
588        NET_ERR_BLOB_DECODE => InnerBlobError::Decode(label.into()),
589        _ => InnerBlobError::Backend(format!("{}: code {}", label, code)),
590    }
591}
592
593/// Extract `(uri, hash, size)` from a [`BlobRef::Small`] for an FFI
594/// vtable call. The C vtable signature only supports single-hash
595/// blobs; chunked dispatch happens at the substrate's
596/// `MeshBlobAdapter` layer above this FFI shim. A
597/// [`BlobRef::Manifest`] passed here is a layering bug; surface
598/// `InnerBlobError::Backend` rather than silently truncating to the
599/// first chunk.
600fn expect_small_for_ffi(
601    blob_ref: &crate::adapter::net::dataforts::BlobRef,
602) -> std::result::Result<(String, [u8; 32], u64), InnerBlobError> {
603    match blob_ref {
604        crate::adapter::net::dataforts::BlobRef::Small {
605            uri, hash, size, ..
606        } => Ok((uri.clone(), *hash, *size)),
607        crate::adapter::net::dataforts::BlobRef::Manifest { .. }
608        | crate::adapter::net::dataforts::BlobRef::Tree { .. } => Err(InnerBlobError::Backend(
609            "CallbackBlobAdapter operates on Small blobs only; \
610                 chunked blobs are dispatched at the substrate above"
611                .to_owned(),
612        )),
613    }
614}
615
616#[async_trait]
617impl BlobAdapter for CallbackBlobAdapter {
618    fn adapter_id(&self) -> &str {
619        &self.id
620    }
621
622    async fn store(
623        &self,
624        blob_ref: &crate::adapter::net::dataforts::BlobRef,
625        bytes: &[u8],
626    ) -> std::result::Result<(), InnerBlobError> {
627        let vtable = self.vtable;
628        let ctx = self.ctx.clone();
629        let (uri_str, hash, size) = expect_small_for_ffi(blob_ref)?;
630        let uri = match std::ffi::CString::new(uri_str) {
631            Ok(c) => c,
632            Err(e) => return Err(InnerBlobError::Backend(format!("uri NUL: {}", e))),
633        };
634        let data = bytes.to_vec();
635        tokio::task::spawn_blocking(move || -> std::result::Result<(), InnerBlobError> {
636            let code = unsafe {
637                (vtable.store)(
638                    ctx.get(),
639                    uri.as_ptr(),
640                    hash.as_ptr(),
641                    size,
642                    data.as_ptr(),
643                    data.len(),
644                )
645            };
646            if code == 0 {
647                Ok(())
648            } else {
649                Err(code_to_err(code, "store"))
650            }
651        })
652        .await
653        .map_err(|e| InnerBlobError::Backend(format!("spawn_blocking join: {}", e)))?
654    }
655
656    async fn fetch(
657        &self,
658        blob_ref: &crate::adapter::net::dataforts::BlobRef,
659    ) -> std::result::Result<Bytes, InnerBlobError> {
660        let vtable = self.vtable;
661        let ctx = self.ctx.clone();
662        let (uri_str, hash, size) = expect_small_for_ffi(blob_ref)?;
663        let uri = match std::ffi::CString::new(uri_str) {
664            Ok(c) => c,
665            Err(e) => return Err(InnerBlobError::Backend(format!("uri NUL: {}", e))),
666        };
667        tokio::task::spawn_blocking(move || -> std::result::Result<Bytes, InnerBlobError> {
668            let mut out_data: *mut u8 = ptr::null_mut();
669            let mut out_len: usize = 0;
670            let code = unsafe {
671                (vtable.fetch)(
672                    ctx.get(),
673                    uri.as_ptr(),
674                    hash.as_ptr(),
675                    size,
676                    &mut out_data,
677                    &mut out_len,
678                )
679            };
680            if code != 0 {
681                return Err(code_to_err(code, "fetch"));
682            }
683            if out_data.is_null() {
684                if out_len == 0 {
685                    return Ok(Bytes::new());
686                }
687                return Err(InnerBlobError::Backend(
688                    "fetch: caller returned null pointer with non-zero len".into(),
689                ));
690            }
691            // Copy out before freeing — the FFI caller owns the
692            // buffer and frees it via free_buffer. We can't hand
693            // the FFI-owned pointer to `Bytes` because rust would
694            // assume Vec-style allocator ownership, so the copy
695            // is unavoidable here (per dataforts perf #184 — the
696            // savings the Bytes signature unlocks are inside the
697            // mesh/fs/noop adapters; FFI callbacks pay the copy
698            // at the boundary in either direction).
699            let buf = unsafe { std::slice::from_raw_parts(out_data, out_len).to_vec() };
700            unsafe { (vtable.free_buffer)(ctx.get(), out_data, out_len) };
701            Ok(Bytes::from(buf))
702        })
703        .await
704        .map_err(|e| InnerBlobError::Backend(format!("spawn_blocking join: {}", e)))?
705    }
706
707    async fn fetch_range(
708        &self,
709        blob_ref: &crate::adapter::net::dataforts::BlobRef,
710        range: Range<u64>,
711    ) -> std::result::Result<Bytes, InnerBlobError> {
712        let vtable = self.vtable;
713        let ctx = self.ctx.clone();
714        let (uri_str, hash, size) = expect_small_for_ffi(blob_ref)?;
715        let uri = match std::ffi::CString::new(uri_str) {
716            Ok(c) => c,
717            Err(e) => return Err(InnerBlobError::Backend(format!("uri NUL: {}", e))),
718        };
719        let start = range.start;
720        let end = range.end;
721        tokio::task::spawn_blocking(move || -> std::result::Result<Bytes, InnerBlobError> {
722            let mut out_data: *mut u8 = ptr::null_mut();
723            let mut out_len: usize = 0;
724            let code = unsafe {
725                (vtable.fetch_range)(
726                    ctx.get(),
727                    uri.as_ptr(),
728                    hash.as_ptr(),
729                    size,
730                    start,
731                    end,
732                    &mut out_data,
733                    &mut out_len,
734                )
735            };
736            if code != 0 {
737                return Err(code_to_err(code, "fetch_range"));
738            }
739            if out_data.is_null() {
740                if out_len == 0 {
741                    return Ok(Bytes::new());
742                }
743                return Err(InnerBlobError::Backend(
744                    "fetch_range: caller returned null pointer with non-zero len".into(),
745                ));
746            }
747            let buf = unsafe { std::slice::from_raw_parts(out_data, out_len).to_vec() };
748            unsafe { (vtable.free_buffer)(ctx.get(), out_data, out_len) };
749            Ok(Bytes::from(buf))
750        })
751        .await
752        .map_err(|e| InnerBlobError::Backend(format!("spawn_blocking join: {}", e)))?
753    }
754
755    async fn exists(
756        &self,
757        blob_ref: &crate::adapter::net::dataforts::BlobRef,
758    ) -> std::result::Result<bool, InnerBlobError> {
759        let vtable = self.vtable;
760        let ctx = self.ctx.clone();
761        let (uri_str, hash, size) = expect_small_for_ffi(blob_ref)?;
762        let uri = match std::ffi::CString::new(uri_str) {
763            Ok(c) => c,
764            Err(e) => return Err(InnerBlobError::Backend(format!("uri NUL: {}", e))),
765        };
766        tokio::task::spawn_blocking(move || -> std::result::Result<bool, InnerBlobError> {
767            let mut out_exists: c_int = 0;
768            let code = unsafe {
769                (vtable.exists)(
770                    ctx.get(),
771                    uri.as_ptr(),
772                    hash.as_ptr(),
773                    size,
774                    &mut out_exists,
775                )
776            };
777            if code != 0 {
778                return Err(code_to_err(code, "exists"));
779            }
780            Ok(out_exists != 0)
781        })
782        .await
783        .map_err(|e| InnerBlobError::Backend(format!("spawn_blocking join: {}", e)))?
784    }
785}
786
787/// Register a C-side BlobAdapter implementation. The vtable is
788/// copied into the adapter; `ctx` is shuttled across every call as
789/// an opaque pointer (caller is responsible for thread-safety).
790///
791/// Returns `0` on success, `NET_ERR_BLOB_DUPLICATE_ID` if `id` is
792/// already registered, or `NetError::InvalidUtf8` / `NullPointer`
793/// for malformed input.
794///
795/// # Safety
796/// - `adapter_id` must point to a valid null-terminated UTF-8 byte
797///   sequence.
798/// - `vtable` must point to a fully-initialised `NetBlobAdapterVtable`
799///   whose function pointers remain valid for the lifetime of the
800///   registration (i.e. until `net_blob_unregister_adapter` returns
801///   AND any in-flight calls have completed).
802/// - `ctx` is an opaque pointer the substrate passes through unchanged
803///   to every vtable call; the caller is responsible for keeping the
804///   pointee alive for the same lifetime as `vtable`.
805///
806/// # Concurrency contract (caller MUST uphold)
807///
808/// The substrate dispatches every vtable call from a
809/// `tokio::task::spawn_blocking` worker. The same `ctx` will be
810/// observed from **multiple OS threads** over the lifetime of the
811/// registration and may be observed **concurrently** when two
812/// in-flight calls are dispatched to the same adapter.
813///
814/// The pointee of `ctx` therefore MUST be:
815/// - safely transferable across threads (`Send`-equivalent in the
816///   caller's runtime); and
817/// - safely accessed concurrently (`Sync`-equivalent), or guarded
818///   inside the vtable callbacks by a caller-owned lock.
819///
820/// Passing a thread-local pointer (an OS thread handle, a Go
821/// goroutine-local pointer, a Python `PyObject*` held outside the
822/// GIL, etc.) is **undefined behaviour**. Wrappers whose runtime
823/// cannot meet the `Sync` requirement MUST serialize vtable
824/// dispatch inside the callback before crossing into the
825/// language runtime.
826#[unsafe(no_mangle)]
827pub unsafe extern "C" fn net_blob_register_callback_adapter(
828    adapter_id: *const c_char,
829    vtable: *const NetBlobAdapterVtable,
830    ctx: *mut c_void,
831) -> c_int {
832    if vtable.is_null() {
833        return NetError::NullPointer.into();
834    }
835    let id = match c_str_to_owned(adapter_id) {
836        Some(s) => s,
837        None => return NetError::InvalidUtf8.into(),
838    };
839    // Validate every fn-ptr field is non-null BEFORE materialising
840    // the vtable as a value-typed `NetBlobAdapterVtable` — Rust's
841    // `unsafe extern "C" fn` type is non-nullable, so loading a
842    // struct whose C-side caller left any field NULL is immediate
843    // UB. Cast each field through a `*const ()` to read the raw
844    // bits without constructing a non-null fn-pointer value.
845    {
846        let raw = vtable as *const c_void as *const *const c_void;
847        // Five fn-ptr fields (store / fetch / fetch_range /
848        // exists / free_buffer). Reading them as *const c_void
849        // gives the raw address without invoking the fn-ptr type's
850        // non-null invariant.
851        for i in 0..5 {
852            let field = unsafe { *raw.add(i) };
853            if field.is_null() {
854                return NET_ERR_BLOB_BACKEND;
855            }
856        }
857    }
858    let vtable = unsafe { *vtable };
859    let adapter: Arc<dyn BlobAdapter> = Arc::new(CallbackBlobAdapter {
860        id: id.clone(),
861        vtable,
862        ctx: Arc::new(OpaqueCtx::new(ctx)),
863    });
864    match global_blob_adapter_registry().register(adapter) {
865        Ok(()) => 0,
866        Err(_) => NET_ERR_BLOB_DUPLICATE_ID,
867    }
868}
869
870// =========================================================================
871// MeshBlobAdapter — v0.2 substrate-owned blob CAS + v0.3 active overflow
872// =========================================================================
873//
874// Mirrors the Node + Python `MeshBlobAdapter` surface for the
875// Go binding via cgo. JSON-encoded configs at the FFI boundary
876// (matches the existing `net_redex_enable_greedy_dataforts` and
877// peers); the Go wrapper marshals from `struct{...}` into the
878// JSON shape before calling these.
879
880/// Opaque handle to a `MeshBlobAdapter`. The Box owns an
881/// `Arc<InnerMeshBlobAdapter>` so multiple handles can share
882/// the adapter — but the FFI surface only ever hands out one
883/// handle per `_new` call; the operator clones at the Go layer
884/// if they want fan-out. Free with [`net_mesh_blob_adapter_free`].
885///
886/// Carries a [`HandleGuard`] inline so a concurrent `_free` racing an
887/// in-flight op cannot deallocate the inner out from under it. Same
888/// quiescing recipe as the cortex / mesh / redis handles: every op
889/// gates on `guard.try_enter()`; `_free` drives `guard.begin_free()`
890/// and leaks the box (dropping only the inner). See
891/// [`super::handle_guard`] for the soundness argument.
892#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
893pub struct MeshBlobAdapterHandle {
894    inner: ManuallyDrop<Arc<InnerMeshBlobAdapter>>,
895    guard: HandleGuard,
896}
897
898#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
899use std::mem::ManuallyDrop;
900
901#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
902use super::handle_guard::{HandleGuard, FFI_HANDLE_FREE_DEADLINE};
903
904/// Run a blob-adapter FFI body under `catch_unwind`. With
905/// `panic = "unwind"`, a panic escaping an `extern "C"` function is UB
906/// across the cgo / N-API / cffi boundary. The shim catches the
907/// unwind, logs, and returns the caller-supplied fallback — matching
908/// the protection `net_blob_publish` / `net_blob_resolve` already
909/// carry, which the metrics / config accessors previously lacked.
910#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
911#[inline]
912fn adapter_guard<R>(name: &'static str, fallback: R, f: impl FnOnce() -> R) -> R {
913    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
914        Ok(v) => v,
915        Err(_) => {
916            tracing::error!(
917                ffi_function = name,
918                "panic caught in mesh blob adapter FFI; returning fallback to avoid \
919                 UB across the C boundary",
920            );
921            fallback
922        }
923    }
924}
925
926/// JSON shape for the `overflow` config option passed to
927/// [`net_mesh_blob_adapter_new`] + [`net_mesh_blob_adapter_set_overflow_config`].
928/// Mirrors the typed `OverflowConfig` from the Rust crate;
929/// `scope` is one of `"node" | "zone" | "region" | "mesh"`.
930#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
931#[derive(serde::Deserialize, serde::Serialize)]
932struct OverflowConfigJson {
933    #[serde(default)]
934    enabled: bool,
935    #[serde(default)]
936    high_water_ratio: Option<f64>,
937    #[serde(default)]
938    low_water_ratio: Option<f64>,
939    #[serde(default)]
940    max_pushes_per_tick: Option<u64>,
941    #[serde(default)]
942    scope: Option<String>,
943    #[serde(default)]
944    tick_interval_ms: Option<u64>,
945}
946
947#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
948fn parse_overflow_json(s: &str) -> Result<InnerOverflowConfig, c_int> {
949    if s.is_empty() {
950        return Ok(InnerOverflowConfig::default());
951    }
952    let raw: OverflowConfigJson =
953        serde_json::from_str(s).map_err(|_| -> c_int { NetError::InvalidJson.into() })?;
954    let mut cfg = InnerOverflowConfig {
955        enabled: raw.enabled,
956        ..InnerOverflowConfig::default()
957    };
958    if let Some(v) = raw.high_water_ratio {
959        cfg.high_water_ratio = v;
960    }
961    if let Some(v) = raw.low_water_ratio {
962        cfg.low_water_ratio = v;
963    }
964    if let Some(v) = raw.max_pushes_per_tick {
965        cfg.max_pushes_per_tick = v as usize;
966    }
967    if let Some(s) = raw.scope {
968        cfg.scope = match s.to_ascii_lowercase().as_str() {
969            "node" => TopologyScope::Node,
970            "zone" => TopologyScope::Zone,
971            "region" => TopologyScope::Region,
972            "mesh" => TopologyScope::Mesh,
973            _ => {
974                let code: c_int = NetError::InvalidJson.into();
975                return Err(code);
976            }
977        };
978    }
979    if let Some(v) = raw.tick_interval_ms {
980        cfg.tick_interval_ms = v;
981    }
982    Ok(cfg)
983}
984
985#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
986fn overflow_to_json(cfg: InnerOverflowConfig) -> String {
987    let scope = match cfg.scope {
988        TopologyScope::Node => "node",
989        TopologyScope::Zone => "zone",
990        TopologyScope::Region => "region",
991        TopologyScope::Mesh => "mesh",
992    };
993    let raw = OverflowConfigJson {
994        enabled: cfg.enabled,
995        high_water_ratio: Some(cfg.high_water_ratio),
996        low_water_ratio: Some(cfg.low_water_ratio),
997        max_pushes_per_tick: Some(cfg.max_pushes_per_tick as u64),
998        scope: Some(scope.to_string()),
999        tick_interval_ms: Some(cfg.tick_interval_ms),
1000    };
1001    serde_json::to_string(&raw).unwrap_or_else(|_| "{}".to_string())
1002}
1003
1004/// Construct a `MeshBlobAdapter` against `redex`.
1005///
1006/// - `redex` — pointer to a `RedexHandle` from `net_redex_new`. The
1007///   adapter clones the inner `Arc<Redex>`; the redex handle stays
1008///   valid after this call.
1009/// - `adapter_id` — null-terminated UTF-8 identity tag.
1010/// - `persistent` — `0` = in-memory chunks; `1` = disk-backed
1011///   (requires the redex to have been opened with a `persistent_dir`).
1012/// - `overflow_json` — null OR null-terminated JSON for the v0.3
1013///   overflow config. Empty string / null = overflow off (the
1014///   v0.2 default).
1015///
1016/// Returns a non-null handle on success. On error returns null and
1017/// sets no errno-equivalent — operators check for null + retry with
1018/// a well-formed JSON config. Free with `net_mesh_blob_adapter_free`.
1019///
1020/// # Safety
1021/// `redex` must be a valid `RedexHandle*` returned from `net_redex_new`
1022/// and not yet freed. `adapter_id` must be a valid null-terminated
1023/// UTF-8 string. `overflow_json` may be null or a valid
1024/// null-terminated UTF-8 JSON string.
1025#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1026#[unsafe(no_mangle)]
1027pub unsafe extern "C" fn net_mesh_blob_adapter_new(
1028    redex: *mut super::cortex::RedexHandle,
1029    adapter_id: *const c_char,
1030    persistent: c_int,
1031    overflow_json: *const c_char,
1032) -> *mut MeshBlobAdapterHandle {
1033    if redex.is_null() {
1034        return ptr::null_mut();
1035    }
1036    let id = match unsafe { c_str_to_owned(adapter_id) } {
1037        Some(s) => s,
1038        None => return ptr::null_mut(),
1039    };
1040    let overflow_str = if overflow_json.is_null() {
1041        String::new()
1042    } else {
1043        match unsafe { c_str_to_owned(overflow_json) } {
1044            Some(s) => s,
1045            None => return ptr::null_mut(),
1046        }
1047    };
1048    let overflow_cfg = match parse_overflow_json(&overflow_str) {
1049        Ok(c) => c,
1050        Err(_) => return ptr::null_mut(),
1051    };
1052    // Gated clone of the redex inner — `None` means the redex handle
1053    // is being freed concurrently; surface a null handle rather than
1054    // racing the inner out of `ManuallyDrop`.
1055    let Some(redex_inner) = (unsafe { (*redex).redex_arc() }) else {
1056        return ptr::null_mut();
1057    };
1058    let mut builder = InnerMeshBlobAdapter::new(id, redex_inner).with_persistent(persistent != 0);
1059    if !overflow_str.is_empty() {
1060        builder = builder.with_overflow(overflow_cfg);
1061    }
1062    Box::into_raw(Box::new(MeshBlobAdapterHandle {
1063        inner: ManuallyDrop::new(Arc::new(builder)),
1064        guard: HandleGuard::new(),
1065    }))
1066}
1067
1068/// Free a handle from [`net_mesh_blob_adapter_new`]. Idempotent
1069/// against a null pointer.
1070///
1071/// # Safety
1072/// `handle` must be a pointer returned by `net_mesh_blob_adapter_new`
1073/// + not yet freed, or null.
1074#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1075#[unsafe(no_mangle)]
1076pub unsafe extern "C" fn net_mesh_blob_adapter_free(handle: *mut MeshBlobAdapterHandle) {
1077    if handle.is_null() {
1078        return;
1079    }
1080    // Quiesce in-flight ops before dropping the inner; the box stays
1081    // leaked (never `Box::from_raw`) so a concurrent op's `try_enter`
1082    // fetch_add still lands on valid memory. See `super::handle_guard`.
1083    let h: &MeshBlobAdapterHandle = unsafe { &*handle };
1084    if h.guard.begin_free(FFI_HANDLE_FREE_DEADLINE) {
1085        // SAFETY: drained; sole writable reference. Single-winner
1086        // contract on `begin_free` makes this `take` happen at most once.
1087        unsafe {
1088            let inner = ManuallyDrop::take(&mut (*handle).inner);
1089            drop(inner);
1090        }
1091    } else {
1092        tracing::warn!(
1093            "net_mesh_blob_adapter_free: in-flight ops did not drain within deadline; \
1094             leaking inner to avoid use-after-free"
1095        );
1096    }
1097}
1098
1099/// Clone the `Arc<MeshBlobAdapter>` backing this handle under the
1100/// handle guard, for the sibling transport FFI (`ffi::transport`). The
1101/// `try_enter` op is held across the `Arc::clone` so a concurrent
1102/// `net_mesh_blob_adapter_free` cannot take the inner out of
1103/// `ManuallyDrop` mid-clone; the bumped refcount then keeps the adapter
1104/// alive independently. Returns `None` once `_free` has begun. Mirrors
1105/// [`super::mesh::mesh_node_arc`].
1106#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1107pub(super) fn blob_adapter_arc(h: &MeshBlobAdapterHandle) -> Option<Arc<InnerMeshBlobAdapter>> {
1108    let _op = h.guard.try_enter()?;
1109    Some(Arc::clone(&h.inner))
1110}
1111
1112/// Store `data` of `data_len` bytes under the content address
1113/// declared by `blob_ref_bytes` (a previously-encoded `BlobRef`
1114/// wire blob from `net_blob_publish` or constructed externally).
1115///
1116/// Returns `0` on success, `NET_ERR_BLOB_*` on adapter-side error,
1117/// or `NetError::NullPointer` / `InvalidUtf8` for input validation.
1118/// The substrate BLAKE3-verifies the bytes against the BlobRef
1119/// hash before persisting.
1120///
1121/// # Safety
1122/// `handle` is a valid `MeshBlobAdapterHandle*`. `blob_ref_bytes`
1123/// + `data` point to readable buffers of the supplied lengths.
1124#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1125#[unsafe(no_mangle)]
1126pub unsafe extern "C" fn net_mesh_blob_adapter_store(
1127    handle: *const MeshBlobAdapterHandle,
1128    blob_ref_bytes: *const u8,
1129    blob_ref_len: usize,
1130    data: *const u8,
1131    data_len: usize,
1132) -> c_int {
1133    let null_rc: c_int = NetError::NullPointer.into();
1134    adapter_guard("net_mesh_blob_adapter_store", null_rc, || {
1135        if handle.is_null() || blob_ref_bytes.is_null() {
1136            return NetError::NullPointer.into();
1137        }
1138        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1139        if blob_ref_len > isize::MAX as usize || data_len > isize::MAX as usize {
1140            return NetError::InvalidJson.into();
1141        }
1142        let h = unsafe { &*handle };
1143        // Bail (same shape as null handle) if `_free` has begun.
1144        let _op = match h.guard.try_enter() {
1145            Some(op) => op,
1146            None => return NetError::NullPointer.into(),
1147        };
1148        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1149        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1150            Ok(Some(b)) => b,
1151            _ => return NET_ERR_BLOB_DECODE,
1152        };
1153        let data_slice = if data.is_null() {
1154            &[]
1155        } else {
1156            unsafe { std::slice::from_raw_parts(data, data_len) }
1157        };
1158        let adapter = h.inner.clone();
1159        let data_owned = data_slice.to_vec();
1160        let result = block_on(async move { (*adapter).store(&blob_ref, &data_owned).await });
1161        match result {
1162            Ok(()) => 0,
1163            Err(e) => err_to_code(&e),
1164        }
1165    })
1166}
1167
1168/// Fetch the content for `blob_ref_bytes`. On success writes a
1169/// heap-allocated buffer pointer to `*out_data` + length to
1170/// `*out_len` and returns `0`. The caller MUST free via
1171/// [`net_blob_free_buffer`].
1172///
1173/// # Safety
1174/// `handle`, `blob_ref_bytes`, `out_data`, `out_len` must all be
1175/// non-null and point to valid memory of the appropriate type.
1176#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1177#[unsafe(no_mangle)]
1178pub unsafe extern "C" fn net_mesh_blob_adapter_fetch(
1179    handle: *const MeshBlobAdapterHandle,
1180    blob_ref_bytes: *const u8,
1181    blob_ref_len: usize,
1182    out_data: *mut *mut u8,
1183    out_len: *mut usize,
1184) -> c_int {
1185    let null_rc: c_int = NetError::NullPointer.into();
1186    adapter_guard("net_mesh_blob_adapter_fetch", null_rc, || {
1187        if handle.is_null() || blob_ref_bytes.is_null() || out_data.is_null() || out_len.is_null() {
1188            return NetError::NullPointer.into();
1189        }
1190        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1191        if blob_ref_len > isize::MAX as usize {
1192            return NetError::InvalidJson.into();
1193        }
1194        let h = unsafe { &*handle };
1195        let _op = match h.guard.try_enter() {
1196            Some(op) => op,
1197            None => return NetError::NullPointer.into(),
1198        };
1199        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1200        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1201            Ok(Some(b)) => b,
1202            _ => return NET_ERR_BLOB_DECODE,
1203        };
1204        let adapter = h.inner.clone();
1205        let result = block_on(async move { (*adapter).fetch(&blob_ref).await });
1206        match result {
1207            // Allocate with the same explicit `Layout::array::<u8>(len)`
1208            // path that `net_blob_free_buffer` deallocates with, so the
1209            // pair is layout-symmetric regardless of any future
1210            // `Vec::leak` / `into_boxed_slice` refactor inside the
1211            // adapter.
1212            Ok(bytes) => unsafe { write_bytes_out(&bytes, out_data, out_len) },
1213            Err(e) => err_to_code(&e),
1214        }
1215    })
1216}
1217
1218/// Probe local presence — writes `1` to `*out_exists` if the chunk
1219/// is locally reachable, `0` otherwise. Returns `0` on success or
1220/// a `NET_ERR_*` code on failure.
1221///
1222/// # Safety
1223/// `handle`, `blob_ref_bytes`, `out_exists` must all be non-null.
1224#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1225#[unsafe(no_mangle)]
1226pub unsafe extern "C" fn net_mesh_blob_adapter_exists(
1227    handle: *const MeshBlobAdapterHandle,
1228    blob_ref_bytes: *const u8,
1229    blob_ref_len: usize,
1230    out_exists: *mut c_int,
1231) -> c_int {
1232    let null_rc: c_int = NetError::NullPointer.into();
1233    adapter_guard("net_mesh_blob_adapter_exists", null_rc, || {
1234        if handle.is_null() || blob_ref_bytes.is_null() || out_exists.is_null() {
1235            return NetError::NullPointer.into();
1236        }
1237        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1238        if blob_ref_len > isize::MAX as usize {
1239            return NetError::InvalidJson.into();
1240        }
1241        let h = unsafe { &*handle };
1242        let _op = match h.guard.try_enter() {
1243            Some(op) => op,
1244            None => return NetError::NullPointer.into(),
1245        };
1246        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1247        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1248            Ok(Some(b)) => b,
1249            _ => return NET_ERR_BLOB_DECODE,
1250        };
1251        let adapter = h.inner.clone();
1252        let result = block_on(async move { (*adapter).exists(&blob_ref).await });
1253        match result {
1254            Ok(present) => {
1255                unsafe { *out_exists = if present { 1 } else { 0 } };
1256                0
1257            }
1258            Err(e) => err_to_code(&e),
1259        }
1260    })
1261}
1262
1263/// Render the adapter's Prometheus text body. Returns a
1264/// `CString::into_raw`-allocated `*mut c_char` that the caller
1265/// MUST free via [`crate::ffi::net_free_string`]. Returns null on
1266/// allocation failure (rare).
1267///
1268/// # Safety
1269/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1270#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1271#[unsafe(no_mangle)]
1272pub unsafe extern "C" fn net_mesh_blob_adapter_prometheus_text(
1273    handle: *const MeshBlobAdapterHandle,
1274) -> *mut c_char {
1275    adapter_guard(
1276        "net_mesh_blob_adapter_prometheus_text",
1277        ptr::null_mut(),
1278        || {
1279            if handle.is_null() {
1280                return ptr::null_mut();
1281            }
1282            let h = unsafe { &*handle };
1283            let _op = match h.guard.try_enter() {
1284                Some(op) => op,
1285                None => return ptr::null_mut(),
1286            };
1287            let adapter = h.inner.clone();
1288            let body = (*adapter).prometheus_text();
1289            match std::ffi::CString::new(body) {
1290                Ok(s) => s.into_raw(),
1291                Err(_) => ptr::null_mut(),
1292            }
1293        },
1294    )
1295}
1296
1297// ---- v0.3 active-overflow surface ----
1298
1299/// True / false for `overflow_enabled` on the adapter. Returns
1300/// `1` / `0`; returns negative `NET_ERR_*` on null handle.
1301///
1302/// # Safety
1303/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1304#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1305#[unsafe(no_mangle)]
1306pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_enabled(
1307    handle: *const MeshBlobAdapterHandle,
1308) -> c_int {
1309    let null_rc: c_int = NetError::NullPointer.into();
1310    adapter_guard("net_mesh_blob_adapter_overflow_enabled", null_rc, || {
1311        if handle.is_null() {
1312            return NetError::NullPointer.into();
1313        }
1314        let h = unsafe { &*handle };
1315        let _op = match h.guard.try_enter() {
1316            Some(op) => op,
1317            None => return NetError::NullPointer.into(),
1318        };
1319        let adapter = h.inner.clone();
1320        if (*adapter).overflow_enabled() {
1321            1
1322        } else {
1323            0
1324        }
1325    })
1326}
1327
1328/// True / false for `overflow_active` (the hysteresis runtime
1329/// state). Same return shape as `_overflow_enabled`.
1330///
1331/// # Safety
1332/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1333#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1334#[unsafe(no_mangle)]
1335pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_active(
1336    handle: *const MeshBlobAdapterHandle,
1337) -> c_int {
1338    let null_rc: c_int = NetError::NullPointer.into();
1339    adapter_guard("net_mesh_blob_adapter_overflow_active", null_rc, || {
1340        if handle.is_null() {
1341            return NetError::NullPointer.into();
1342        }
1343        let h = unsafe { &*handle };
1344        let _op = match h.guard.try_enter() {
1345            Some(op) => op,
1346            None => return NetError::NullPointer.into(),
1347        };
1348        let adapter = h.inner.clone();
1349        if (*adapter).overflow_active() {
1350            1
1351        } else {
1352            0
1353        }
1354    })
1355}
1356
1357/// Snapshot the current overflow configuration as a JSON
1358/// string. Returns a `CString::into_raw`-allocated `*mut c_char`
1359/// the caller MUST free via [`crate::ffi::net_free_string`].
1360///
1361/// # Safety
1362/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1363#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1364#[unsafe(no_mangle)]
1365pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_config(
1366    handle: *const MeshBlobAdapterHandle,
1367) -> *mut c_char {
1368    adapter_guard(
1369        "net_mesh_blob_adapter_overflow_config",
1370        ptr::null_mut(),
1371        || {
1372            if handle.is_null() {
1373                return ptr::null_mut();
1374            }
1375            let h = unsafe { &*handle };
1376            let _op = match h.guard.try_enter() {
1377                Some(op) => op,
1378                None => return ptr::null_mut(),
1379            };
1380            let adapter = h.inner.clone();
1381            let cfg = (*adapter).overflow_config();
1382            let json = overflow_to_json(cfg);
1383            match std::ffi::CString::new(json) {
1384                Ok(s) => s.into_raw(),
1385                Err(_) => ptr::null_mut(),
1386            }
1387        },
1388    )
1389}
1390
1391/// Flip the overflow master switch. Returns `0` on success,
1392/// `NET_ERR_*` on null handle.
1393///
1394/// # Safety
1395/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1396#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1397#[unsafe(no_mangle)]
1398pub unsafe extern "C" fn net_mesh_blob_adapter_set_overflow_enabled(
1399    handle: *const MeshBlobAdapterHandle,
1400    enabled: c_int,
1401) -> c_int {
1402    let null_rc: c_int = NetError::NullPointer.into();
1403    adapter_guard(
1404        "net_mesh_blob_adapter_set_overflow_enabled",
1405        null_rc,
1406        || {
1407            if handle.is_null() {
1408                return NetError::NullPointer.into();
1409            }
1410            let h = unsafe { &*handle };
1411            let _op = match h.guard.try_enter() {
1412                Some(op) => op,
1413                None => return NetError::NullPointer.into(),
1414            };
1415            let adapter = h.inner.clone();
1416            (*adapter).set_overflow_enabled(enabled != 0);
1417            0
1418        },
1419    )
1420}
1421
1422/// Replace the entire overflow configuration with the JSON
1423/// shape `config_json`. Returns `0` on success,
1424/// `NetError::InvalidJson` on malformed input.
1425///
1426/// # Safety
1427/// `handle` + `config_json` must be valid. `config_json` must be a
1428/// null-terminated UTF-8 JSON string.
1429#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1430#[unsafe(no_mangle)]
1431pub unsafe extern "C" fn net_mesh_blob_adapter_set_overflow_config(
1432    handle: *const MeshBlobAdapterHandle,
1433    config_json: *const c_char,
1434) -> c_int {
1435    let null_rc: c_int = NetError::NullPointer.into();
1436    adapter_guard("net_mesh_blob_adapter_set_overflow_config", null_rc, || {
1437        if handle.is_null() || config_json.is_null() {
1438            return NetError::NullPointer.into();
1439        }
1440        let s = match unsafe { c_str_to_owned(config_json) } {
1441            Some(s) => s,
1442            None => return NetError::InvalidUtf8.into(),
1443        };
1444        let cfg = match parse_overflow_json(&s) {
1445            Ok(c) => c,
1446            Err(code) => return code,
1447        };
1448        let h = unsafe { &*handle };
1449        let _op = match h.guard.try_enter() {
1450            Some(op) => op,
1451            None => return NetError::NullPointer.into(),
1452        };
1453        let adapter = h.inner.clone();
1454        (*adapter).set_overflow_config(cfg);
1455        0
1456    })
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    #![allow(
1462        clippy::disallowed_methods,
1463        reason = "test code legitimately uses std::sync::{Mutex,RwLock} for SUT setup; tests have no real poison concern"
1464    )]
1465    use super::*;
1466    use std::ffi::CString;
1467    use std::sync::atomic::{AtomicU64, Ordering};
1468
1469    fn unique_id(prefix: &str) -> String {
1470        static N: AtomicU64 = AtomicU64::new(0);
1471        let n = N.fetch_add(1, Ordering::Relaxed);
1472        format!("{}-{}-{}", prefix, std::process::id(), n)
1473    }
1474
1475    /// End-to-end: register FS adapter, publish, resolve, free.
1476    /// Pins the contract on the symbols Go / C consumers will use.
1477    #[test]
1478    fn ffi_publish_resolve_round_trip() {
1479        let id = unique_id("ffi-blob");
1480        let root = std::env::temp_dir().join(format!("net-ffi-blob-{}", id));
1481        let id_c = CString::new(id.clone()).unwrap();
1482        let root_c = CString::new(root.to_string_lossy().as_ref()).unwrap();
1483        let uri_c = CString::new("file:///ffi-round-trip").unwrap();
1484
1485        unsafe {
1486            assert_eq!(
1487                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1488                0
1489            );
1490            assert_eq!(net_blob_adapter_registered(id_c.as_ptr()), 1);
1491
1492            let payload = b"end-to-end ffi blob round trip";
1493            let mut out_buf: *mut u8 = std::ptr::null_mut();
1494            let mut out_len: usize = 0;
1495            let rc = net_blob_publish(
1496                id_c.as_ptr(),
1497                uri_c.as_ptr(),
1498                payload.as_ptr(),
1499                payload.len(),
1500                &mut out_buf,
1501                &mut out_len,
1502            );
1503            assert_eq!(rc, 0);
1504            assert!(!out_buf.is_null());
1505            // First bytes are the BlobRef magic.
1506            let encoded = std::slice::from_raw_parts(out_buf, out_len);
1507            assert_eq!(
1508                &encoded[..4],
1509                &crate::adapter::net::dataforts::BLOB_REF_MAGIC,
1510            );
1511
1512            // Resolve back through the same adapter.
1513            let mut content_buf: *mut u8 = std::ptr::null_mut();
1514            let mut content_len: usize = 0;
1515            let rc = net_blob_resolve(
1516                id_c.as_ptr(),
1517                out_buf,
1518                out_len,
1519                &mut content_buf,
1520                &mut content_len,
1521            );
1522            assert_eq!(rc, 0);
1523            let resolved = std::slice::from_raw_parts(content_buf, content_len);
1524            assert_eq!(resolved, payload);
1525
1526            net_blob_free_buffer(out_buf, out_len);
1527            net_blob_free_buffer(content_buf, content_len);
1528            assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1529        }
1530        let _ = std::fs::remove_dir_all(&root);
1531    }
1532
1533    #[test]
1534    fn ffi_resolve_returns_not_registered_for_unknown_adapter() {
1535        let id_c = CString::new("never-registered").unwrap();
1536        let payload = b"any";
1537        let mut out_buf: *mut u8 = std::ptr::null_mut();
1538        let mut out_len: usize = 0;
1539        let rc = unsafe {
1540            net_blob_resolve(
1541                id_c.as_ptr(),
1542                payload.as_ptr(),
1543                payload.len(),
1544                &mut out_buf,
1545                &mut out_len,
1546            )
1547        };
1548        assert_eq!(rc, NET_ERR_BLOB_NOT_REGISTERED);
1549        assert!(out_buf.is_null());
1550        assert_eq!(out_len, 0);
1551    }
1552
1553    /// Round-trip an `net_blob_register_callback_adapter`-registered
1554    /// adapter: publish bytes through the vtable, then resolve them
1555    /// back. The vtable's `fetch` returns bytes from a static map
1556    /// indexed by the BLAKE3 hash; the substrate-side hash check
1557    /// validates the round trip.
1558    mod callback_adapter_round_trip {
1559        use super::*;
1560        use std::collections::HashMap;
1561        use std::sync::Mutex;
1562
1563        struct CallbackCtx {
1564            store: Mutex<HashMap<[u8; 32], Vec<u8>>>,
1565        }
1566
1567        unsafe extern "C" fn cb_store(
1568            ctx: *mut c_void,
1569            _uri: *const c_char,
1570            hash: *const u8,
1571            _size: u64,
1572            data: *const u8,
1573            data_len: usize,
1574        ) -> c_int {
1575            let ctx = &*(ctx as *const CallbackCtx);
1576            let mut h = [0u8; 32];
1577            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1578            let buf = if data_len == 0 {
1579                Vec::new()
1580            } else {
1581                std::slice::from_raw_parts(data, data_len).to_vec()
1582            };
1583            ctx.store.lock().unwrap().insert(h, buf);
1584            0
1585        }
1586
1587        unsafe extern "C" fn cb_fetch(
1588            ctx: *mut c_void,
1589            _uri: *const c_char,
1590            hash: *const u8,
1591            _size: u64,
1592            out_data: *mut *mut u8,
1593            out_len: *mut usize,
1594        ) -> c_int {
1595            let ctx = &*(ctx as *const CallbackCtx);
1596            let mut h = [0u8; 32];
1597            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1598            let store = ctx.store.lock().unwrap();
1599            match store.get(&h) {
1600                Some(bytes) => {
1601                    let boxed = bytes.clone().into_boxed_slice();
1602                    let len = boxed.len();
1603                    let ptr = Box::into_raw(boxed) as *mut u8;
1604                    *out_data = ptr;
1605                    *out_len = len;
1606                    0
1607                }
1608                None => NET_ERR_BLOB_NOT_FOUND,
1609            }
1610        }
1611
1612        unsafe extern "C" fn cb_fetch_range(
1613            ctx: *mut c_void,
1614            _uri: *const c_char,
1615            hash: *const u8,
1616            _size: u64,
1617            range_start: u64,
1618            range_end: u64,
1619            out_data: *mut *mut u8,
1620            out_len: *mut usize,
1621        ) -> c_int {
1622            let ctx = &*(ctx as *const CallbackCtx);
1623            let mut h = [0u8; 32];
1624            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1625            let store = ctx.store.lock().unwrap();
1626            match store.get(&h) {
1627                Some(bytes) => {
1628                    let s = range_start as usize;
1629                    let e = range_end as usize;
1630                    if s > e || e > bytes.len() {
1631                        return NET_ERR_BLOB_BACKEND;
1632                    }
1633                    let slice = bytes[s..e].to_vec().into_boxed_slice();
1634                    let len = slice.len();
1635                    *out_data = Box::into_raw(slice) as *mut u8;
1636                    *out_len = len;
1637                    0
1638                }
1639                None => NET_ERR_BLOB_NOT_FOUND,
1640            }
1641        }
1642
1643        unsafe extern "C" fn cb_exists(
1644            ctx: *mut c_void,
1645            _uri: *const c_char,
1646            hash: *const u8,
1647            _size: u64,
1648            out_exists: *mut c_int,
1649        ) -> c_int {
1650            let ctx = &*(ctx as *const CallbackCtx);
1651            let mut h = [0u8; 32];
1652            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1653            *out_exists = if ctx.store.lock().unwrap().contains_key(&h) {
1654                1
1655            } else {
1656                0
1657            };
1658            0
1659        }
1660
1661        unsafe extern "C" fn cb_free(_ctx: *mut c_void, data: *mut u8, len: usize) {
1662            if data.is_null() {
1663                return;
1664            }
1665            let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len));
1666        }
1667
1668        #[test]
1669        fn callback_adapter_publish_resolve_round_trip() {
1670            let ctx = Box::new(CallbackCtx {
1671                store: Mutex::new(HashMap::new()),
1672            });
1673            let ctx_ptr = Box::into_raw(ctx) as *mut c_void;
1674            let vtable = NetBlobAdapterVtable {
1675                store: cb_store,
1676                fetch: cb_fetch,
1677                fetch_range: cb_fetch_range,
1678                exists: cb_exists,
1679                free_buffer: cb_free,
1680            };
1681
1682            let id_c = std::ffi::CString::new("ffi-cb-roundtrip").unwrap();
1683            let uri_c = std::ffi::CString::new("cb://round-trip").unwrap();
1684            unsafe {
1685                assert_eq!(
1686                    net_blob_register_callback_adapter(id_c.as_ptr(), &vtable, ctx_ptr),
1687                    0
1688                );
1689
1690                let payload = b"vtable round-trip payload";
1691                let mut out_buf: *mut u8 = std::ptr::null_mut();
1692                let mut out_len: usize = 0;
1693                let rc = net_blob_publish(
1694                    id_c.as_ptr(),
1695                    uri_c.as_ptr(),
1696                    payload.as_ptr(),
1697                    payload.len(),
1698                    &mut out_buf,
1699                    &mut out_len,
1700                );
1701                assert_eq!(rc, 0);
1702
1703                let mut content_buf: *mut u8 = std::ptr::null_mut();
1704                let mut content_len: usize = 0;
1705                let rc = net_blob_resolve(
1706                    id_c.as_ptr(),
1707                    out_buf,
1708                    out_len,
1709                    &mut content_buf,
1710                    &mut content_len,
1711                );
1712                assert_eq!(rc, 0);
1713                let resolved = std::slice::from_raw_parts(content_buf, content_len);
1714                assert_eq!(resolved, payload);
1715
1716                net_blob_free_buffer(out_buf, out_len);
1717                net_blob_free_buffer(content_buf, content_len);
1718                assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1719
1720                // Reclaim the leaked ctx box.
1721                drop(Box::from_raw(ctx_ptr as *mut CallbackCtx));
1722            }
1723        }
1724    }
1725
1726    #[test]
1727    fn ffi_duplicate_registration_rejected() {
1728        let id = unique_id("ffi-dup");
1729        let root = std::env::temp_dir().join(format!("net-ffi-blob-{}", id));
1730        let id_c = CString::new(id.clone()).unwrap();
1731        let root_c = CString::new(root.to_string_lossy().as_ref()).unwrap();
1732        unsafe {
1733            assert_eq!(
1734                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1735                0
1736            );
1737            assert_eq!(
1738                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1739                NET_ERR_BLOB_DUPLICATE_ID
1740            );
1741            assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1742        }
1743        let _ = std::fs::remove_dir_all(&root);
1744    }
1745
1746    /// Regression for the `MeshBlobAdapterHandle` use-after-free /
1747    /// double-free (security audit H1). Pre-fix the handle had no
1748    /// `HandleGuard`; `_free` did an unconditional `Box::from_raw`,
1749    /// so an op racing `_free` read freed memory and a second `_free`
1750    /// was a double-free.
1751    ///
1752    /// Post-fix the box is leaked on `_free` (only the inner is
1753    /// dropped) and every op gates on `guard.try_enter()`. This makes
1754    /// two properties observable + deterministic:
1755    ///   1. An op on a freed handle bails with the null-pointer code
1756    ///      (reading the still-valid leaked guard) instead of UB.
1757    ///   2. A second `_free` is a no-op (single-winner `begin_free`),
1758    ///      not a double-free.
1759    #[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1760    #[test]
1761    fn blob_adapter_ops_after_free_bail_and_double_free_is_safe() {
1762        use crate::ffi::cortex::{net_redex_free, net_redex_new};
1763
1764        let null_rc: c_int = NetError::NullPointer.into();
1765        let id_c = CString::new(unique_id("ffi-blob-adapter-uaf")).unwrap();
1766
1767        unsafe {
1768            // In-memory redex (NULL persistent_dir) → no disk needed.
1769            let redex = net_redex_new(std::ptr::null());
1770            assert!(!redex.is_null());
1771
1772            // persistent = 0 (in-memory chunks), overflow_json = NULL.
1773            let adapter = net_mesh_blob_adapter_new(redex, id_c.as_ptr(), 0, std::ptr::null());
1774            assert!(!adapter.is_null(), "adapter must construct");
1775
1776            // While live, the metrics accessors return valid results.
1777            let live = net_mesh_blob_adapter_overflow_enabled(adapter);
1778            assert!(live == 0 || live == 1, "live overflow_enabled in {{0,1}}");
1779
1780            // Free once.
1781            net_mesh_blob_adapter_free(adapter);
1782
1783            // Ops on the freed handle must bail (guard.freeing == true →
1784            // try_enter == None), NOT UAF. The leaked box keeps the
1785            // guard readable.
1786            assert_eq!(
1787                net_mesh_blob_adapter_overflow_enabled(adapter),
1788                null_rc,
1789                "op on freed handle must return the null-pointer bail code",
1790            );
1791            assert_eq!(net_mesh_blob_adapter_overflow_active(adapter), null_rc);
1792            assert!(
1793                net_mesh_blob_adapter_prometheus_text(adapter).is_null(),
1794                "ptr-returning op on freed handle must return null",
1795            );
1796            let blob_ref = [0u8; 4];
1797            assert_eq!(
1798                net_mesh_blob_adapter_store(
1799                    adapter,
1800                    blob_ref.as_ptr(),
1801                    blob_ref.len(),
1802                    std::ptr::null(),
1803                    0,
1804                ),
1805                null_rc,
1806                "store on freed handle must bail, not run against freed inner",
1807            );
1808
1809            // Double free must be safe (single-winner begin_free).
1810            net_mesh_blob_adapter_free(adapter);
1811
1812            // NULL handle is a no-op for every entry point.
1813            net_mesh_blob_adapter_free(std::ptr::null_mut());
1814            assert_eq!(
1815                net_mesh_blob_adapter_overflow_enabled(std::ptr::null()),
1816                null_rc,
1817            );
1818
1819            net_redex_free(redex);
1820        }
1821    }
1822}