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/// Store `data` of `data_len` bytes under the content address
1100/// declared by `blob_ref_bytes` (a previously-encoded `BlobRef`
1101/// wire blob from `net_blob_publish` or constructed externally).
1102///
1103/// Returns `0` on success, `NET_ERR_BLOB_*` on adapter-side error,
1104/// or `NetError::NullPointer` / `InvalidUtf8` for input validation.
1105/// The substrate BLAKE3-verifies the bytes against the BlobRef
1106/// hash before persisting.
1107///
1108/// # Safety
1109/// `handle` is a valid `MeshBlobAdapterHandle*`. `blob_ref_bytes`
1110/// + `data` point to readable buffers of the supplied lengths.
1111#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1112#[unsafe(no_mangle)]
1113pub unsafe extern "C" fn net_mesh_blob_adapter_store(
1114    handle: *const MeshBlobAdapterHandle,
1115    blob_ref_bytes: *const u8,
1116    blob_ref_len: usize,
1117    data: *const u8,
1118    data_len: usize,
1119) -> c_int {
1120    let null_rc: c_int = NetError::NullPointer.into();
1121    adapter_guard("net_mesh_blob_adapter_store", null_rc, || {
1122        if handle.is_null() || blob_ref_bytes.is_null() {
1123            return NetError::NullPointer.into();
1124        }
1125        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1126        if blob_ref_len > isize::MAX as usize || data_len > isize::MAX as usize {
1127            return NetError::InvalidJson.into();
1128        }
1129        let h = unsafe { &*handle };
1130        // Bail (same shape as null handle) if `_free` has begun.
1131        let _op = match h.guard.try_enter() {
1132            Some(op) => op,
1133            None => return NetError::NullPointer.into(),
1134        };
1135        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1136        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1137            Ok(Some(b)) => b,
1138            _ => return NET_ERR_BLOB_DECODE,
1139        };
1140        let data_slice = if data.is_null() {
1141            &[]
1142        } else {
1143            unsafe { std::slice::from_raw_parts(data, data_len) }
1144        };
1145        let adapter = h.inner.clone();
1146        let data_owned = data_slice.to_vec();
1147        let result = block_on(async move { (*adapter).store(&blob_ref, &data_owned).await });
1148        match result {
1149            Ok(()) => 0,
1150            Err(e) => err_to_code(&e),
1151        }
1152    })
1153}
1154
1155/// Fetch the content for `blob_ref_bytes`. On success writes a
1156/// heap-allocated buffer pointer to `*out_data` + length to
1157/// `*out_len` and returns `0`. The caller MUST free via
1158/// [`net_blob_free_buffer`].
1159///
1160/// # Safety
1161/// `handle`, `blob_ref_bytes`, `out_data`, `out_len` must all be
1162/// non-null and point to valid memory of the appropriate type.
1163#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1164#[unsafe(no_mangle)]
1165pub unsafe extern "C" fn net_mesh_blob_adapter_fetch(
1166    handle: *const MeshBlobAdapterHandle,
1167    blob_ref_bytes: *const u8,
1168    blob_ref_len: usize,
1169    out_data: *mut *mut u8,
1170    out_len: *mut usize,
1171) -> c_int {
1172    let null_rc: c_int = NetError::NullPointer.into();
1173    adapter_guard("net_mesh_blob_adapter_fetch", null_rc, || {
1174        if handle.is_null() || blob_ref_bytes.is_null() || out_data.is_null() || out_len.is_null() {
1175            return NetError::NullPointer.into();
1176        }
1177        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1178        if blob_ref_len > isize::MAX as usize {
1179            return NetError::InvalidJson.into();
1180        }
1181        let h = unsafe { &*handle };
1182        let _op = match h.guard.try_enter() {
1183            Some(op) => op,
1184            None => return NetError::NullPointer.into(),
1185        };
1186        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1187        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1188            Ok(Some(b)) => b,
1189            _ => return NET_ERR_BLOB_DECODE,
1190        };
1191        let adapter = h.inner.clone();
1192        let result = block_on(async move { (*adapter).fetch(&blob_ref).await });
1193        match result {
1194            // Allocate with the same explicit `Layout::array::<u8>(len)`
1195            // path that `net_blob_free_buffer` deallocates with, so the
1196            // pair is layout-symmetric regardless of any future
1197            // `Vec::leak` / `into_boxed_slice` refactor inside the
1198            // adapter.
1199            Ok(bytes) => unsafe { write_bytes_out(&bytes, out_data, out_len) },
1200            Err(e) => err_to_code(&e),
1201        }
1202    })
1203}
1204
1205/// Probe local presence — writes `1` to `*out_exists` if the chunk
1206/// is locally reachable, `0` otherwise. Returns `0` on success or
1207/// a `NET_ERR_*` code on failure.
1208///
1209/// # Safety
1210/// `handle`, `blob_ref_bytes`, `out_exists` must all be non-null.
1211#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1212#[unsafe(no_mangle)]
1213pub unsafe extern "C" fn net_mesh_blob_adapter_exists(
1214    handle: *const MeshBlobAdapterHandle,
1215    blob_ref_bytes: *const u8,
1216    blob_ref_len: usize,
1217    out_exists: *mut c_int,
1218) -> c_int {
1219    let null_rc: c_int = NetError::NullPointer.into();
1220    adapter_guard("net_mesh_blob_adapter_exists", null_rc, || {
1221        if handle.is_null() || blob_ref_bytes.is_null() || out_exists.is_null() {
1222            return NetError::NullPointer.into();
1223        }
1224        // `slice::from_raw_parts` requires `len <= isize::MAX`.
1225        if blob_ref_len > isize::MAX as usize {
1226            return NetError::InvalidJson.into();
1227        }
1228        let h = unsafe { &*handle };
1229        let _op = match h.guard.try_enter() {
1230            Some(op) => op,
1231            None => return NetError::NullPointer.into(),
1232        };
1233        let blob_slice = unsafe { std::slice::from_raw_parts(blob_ref_bytes, blob_ref_len) };
1234        let blob_ref = match InnerBlobRef::decode(blob_slice) {
1235            Ok(Some(b)) => b,
1236            _ => return NET_ERR_BLOB_DECODE,
1237        };
1238        let adapter = h.inner.clone();
1239        let result = block_on(async move { (*adapter).exists(&blob_ref).await });
1240        match result {
1241            Ok(present) => {
1242                unsafe { *out_exists = if present { 1 } else { 0 } };
1243                0
1244            }
1245            Err(e) => err_to_code(&e),
1246        }
1247    })
1248}
1249
1250/// Render the adapter's Prometheus text body. Returns a
1251/// `CString::into_raw`-allocated `*mut c_char` that the caller
1252/// MUST free via [`crate::ffi::net_free_string`]. Returns null on
1253/// allocation failure (rare).
1254///
1255/// # Safety
1256/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1257#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1258#[unsafe(no_mangle)]
1259pub unsafe extern "C" fn net_mesh_blob_adapter_prometheus_text(
1260    handle: *const MeshBlobAdapterHandle,
1261) -> *mut c_char {
1262    adapter_guard(
1263        "net_mesh_blob_adapter_prometheus_text",
1264        ptr::null_mut(),
1265        || {
1266            if handle.is_null() {
1267                return ptr::null_mut();
1268            }
1269            let h = unsafe { &*handle };
1270            let _op = match h.guard.try_enter() {
1271                Some(op) => op,
1272                None => return ptr::null_mut(),
1273            };
1274            let adapter = h.inner.clone();
1275            let body = (*adapter).prometheus_text();
1276            match std::ffi::CString::new(body) {
1277                Ok(s) => s.into_raw(),
1278                Err(_) => ptr::null_mut(),
1279            }
1280        },
1281    )
1282}
1283
1284// ---- v0.3 active-overflow surface ----
1285
1286/// True / false for `overflow_enabled` on the adapter. Returns
1287/// `1` / `0`; returns negative `NET_ERR_*` on null handle.
1288///
1289/// # Safety
1290/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1291#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1292#[unsafe(no_mangle)]
1293pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_enabled(
1294    handle: *const MeshBlobAdapterHandle,
1295) -> c_int {
1296    let null_rc: c_int = NetError::NullPointer.into();
1297    adapter_guard("net_mesh_blob_adapter_overflow_enabled", null_rc, || {
1298        if handle.is_null() {
1299            return NetError::NullPointer.into();
1300        }
1301        let h = unsafe { &*handle };
1302        let _op = match h.guard.try_enter() {
1303            Some(op) => op,
1304            None => return NetError::NullPointer.into(),
1305        };
1306        let adapter = h.inner.clone();
1307        if (*adapter).overflow_enabled() {
1308            1
1309        } else {
1310            0
1311        }
1312    })
1313}
1314
1315/// True / false for `overflow_active` (the hysteresis runtime
1316/// state). Same return shape as `_overflow_enabled`.
1317///
1318/// # Safety
1319/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1320#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1321#[unsafe(no_mangle)]
1322pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_active(
1323    handle: *const MeshBlobAdapterHandle,
1324) -> c_int {
1325    let null_rc: c_int = NetError::NullPointer.into();
1326    adapter_guard("net_mesh_blob_adapter_overflow_active", null_rc, || {
1327        if handle.is_null() {
1328            return NetError::NullPointer.into();
1329        }
1330        let h = unsafe { &*handle };
1331        let _op = match h.guard.try_enter() {
1332            Some(op) => op,
1333            None => return NetError::NullPointer.into(),
1334        };
1335        let adapter = h.inner.clone();
1336        if (*adapter).overflow_active() {
1337            1
1338        } else {
1339            0
1340        }
1341    })
1342}
1343
1344/// Snapshot the current overflow configuration as a JSON
1345/// string. Returns a `CString::into_raw`-allocated `*mut c_char`
1346/// the caller MUST free via [`crate::ffi::net_free_string`].
1347///
1348/// # Safety
1349/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1350#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1351#[unsafe(no_mangle)]
1352pub unsafe extern "C" fn net_mesh_blob_adapter_overflow_config(
1353    handle: *const MeshBlobAdapterHandle,
1354) -> *mut c_char {
1355    adapter_guard(
1356        "net_mesh_blob_adapter_overflow_config",
1357        ptr::null_mut(),
1358        || {
1359            if handle.is_null() {
1360                return ptr::null_mut();
1361            }
1362            let h = unsafe { &*handle };
1363            let _op = match h.guard.try_enter() {
1364                Some(op) => op,
1365                None => return ptr::null_mut(),
1366            };
1367            let adapter = h.inner.clone();
1368            let cfg = (*adapter).overflow_config();
1369            let json = overflow_to_json(cfg);
1370            match std::ffi::CString::new(json) {
1371                Ok(s) => s.into_raw(),
1372                Err(_) => ptr::null_mut(),
1373            }
1374        },
1375    )
1376}
1377
1378/// Flip the overflow master switch. Returns `0` on success,
1379/// `NET_ERR_*` on null handle.
1380///
1381/// # Safety
1382/// `handle` must be a valid `MeshBlobAdapterHandle*`.
1383#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1384#[unsafe(no_mangle)]
1385pub unsafe extern "C" fn net_mesh_blob_adapter_set_overflow_enabled(
1386    handle: *const MeshBlobAdapterHandle,
1387    enabled: c_int,
1388) -> c_int {
1389    let null_rc: c_int = NetError::NullPointer.into();
1390    adapter_guard(
1391        "net_mesh_blob_adapter_set_overflow_enabled",
1392        null_rc,
1393        || {
1394            if handle.is_null() {
1395                return NetError::NullPointer.into();
1396            }
1397            let h = unsafe { &*handle };
1398            let _op = match h.guard.try_enter() {
1399                Some(op) => op,
1400                None => return NetError::NullPointer.into(),
1401            };
1402            let adapter = h.inner.clone();
1403            (*adapter).set_overflow_enabled(enabled != 0);
1404            0
1405        },
1406    )
1407}
1408
1409/// Replace the entire overflow configuration with the JSON
1410/// shape `config_json`. Returns `0` on success,
1411/// `NetError::InvalidJson` on malformed input.
1412///
1413/// # Safety
1414/// `handle` + `config_json` must be valid. `config_json` must be a
1415/// null-terminated UTF-8 JSON string.
1416#[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1417#[unsafe(no_mangle)]
1418pub unsafe extern "C" fn net_mesh_blob_adapter_set_overflow_config(
1419    handle: *const MeshBlobAdapterHandle,
1420    config_json: *const c_char,
1421) -> c_int {
1422    let null_rc: c_int = NetError::NullPointer.into();
1423    adapter_guard("net_mesh_blob_adapter_set_overflow_config", null_rc, || {
1424        if handle.is_null() || config_json.is_null() {
1425            return NetError::NullPointer.into();
1426        }
1427        let s = match unsafe { c_str_to_owned(config_json) } {
1428            Some(s) => s,
1429            None => return NetError::InvalidUtf8.into(),
1430        };
1431        let cfg = match parse_overflow_json(&s) {
1432            Ok(c) => c,
1433            Err(code) => return code,
1434        };
1435        let h = unsafe { &*handle };
1436        let _op = match h.guard.try_enter() {
1437            Some(op) => op,
1438            None => return NetError::NullPointer.into(),
1439        };
1440        let adapter = h.inner.clone();
1441        (*adapter).set_overflow_config(cfg);
1442        0
1443    })
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448    #![allow(
1449        clippy::disallowed_methods,
1450        reason = "test code legitimately uses std::sync::{Mutex,RwLock} for SUT setup; tests have no real poison concern"
1451    )]
1452    use super::*;
1453    use std::ffi::CString;
1454    use std::sync::atomic::{AtomicU64, Ordering};
1455
1456    fn unique_id(prefix: &str) -> String {
1457        static N: AtomicU64 = AtomicU64::new(0);
1458        let n = N.fetch_add(1, Ordering::Relaxed);
1459        format!("{}-{}-{}", prefix, std::process::id(), n)
1460    }
1461
1462    /// End-to-end: register FS adapter, publish, resolve, free.
1463    /// Pins the contract on the symbols Go / C consumers will use.
1464    #[test]
1465    fn ffi_publish_resolve_round_trip() {
1466        let id = unique_id("ffi-blob");
1467        let root = std::env::temp_dir().join(format!("net-ffi-blob-{}", id));
1468        let id_c = CString::new(id.clone()).unwrap();
1469        let root_c = CString::new(root.to_string_lossy().as_ref()).unwrap();
1470        let uri_c = CString::new("file:///ffi-round-trip").unwrap();
1471
1472        unsafe {
1473            assert_eq!(
1474                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1475                0
1476            );
1477            assert_eq!(net_blob_adapter_registered(id_c.as_ptr()), 1);
1478
1479            let payload = b"end-to-end ffi blob round trip";
1480            let mut out_buf: *mut u8 = std::ptr::null_mut();
1481            let mut out_len: usize = 0;
1482            let rc = net_blob_publish(
1483                id_c.as_ptr(),
1484                uri_c.as_ptr(),
1485                payload.as_ptr(),
1486                payload.len(),
1487                &mut out_buf,
1488                &mut out_len,
1489            );
1490            assert_eq!(rc, 0);
1491            assert!(!out_buf.is_null());
1492            // First bytes are the BlobRef magic.
1493            let encoded = std::slice::from_raw_parts(out_buf, out_len);
1494            assert_eq!(
1495                &encoded[..4],
1496                &crate::adapter::net::dataforts::BLOB_REF_MAGIC,
1497            );
1498
1499            // Resolve back through the same adapter.
1500            let mut content_buf: *mut u8 = std::ptr::null_mut();
1501            let mut content_len: usize = 0;
1502            let rc = net_blob_resolve(
1503                id_c.as_ptr(),
1504                out_buf,
1505                out_len,
1506                &mut content_buf,
1507                &mut content_len,
1508            );
1509            assert_eq!(rc, 0);
1510            let resolved = std::slice::from_raw_parts(content_buf, content_len);
1511            assert_eq!(resolved, payload);
1512
1513            net_blob_free_buffer(out_buf, out_len);
1514            net_blob_free_buffer(content_buf, content_len);
1515            assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1516        }
1517        let _ = std::fs::remove_dir_all(&root);
1518    }
1519
1520    #[test]
1521    fn ffi_resolve_returns_not_registered_for_unknown_adapter() {
1522        let id_c = CString::new("never-registered").unwrap();
1523        let payload = b"any";
1524        let mut out_buf: *mut u8 = std::ptr::null_mut();
1525        let mut out_len: usize = 0;
1526        let rc = unsafe {
1527            net_blob_resolve(
1528                id_c.as_ptr(),
1529                payload.as_ptr(),
1530                payload.len(),
1531                &mut out_buf,
1532                &mut out_len,
1533            )
1534        };
1535        assert_eq!(rc, NET_ERR_BLOB_NOT_REGISTERED);
1536        assert!(out_buf.is_null());
1537        assert_eq!(out_len, 0);
1538    }
1539
1540    /// Round-trip an `net_blob_register_callback_adapter`-registered
1541    /// adapter: publish bytes through the vtable, then resolve them
1542    /// back. The vtable's `fetch` returns bytes from a static map
1543    /// indexed by the BLAKE3 hash; the substrate-side hash check
1544    /// validates the round trip.
1545    mod callback_adapter_round_trip {
1546        use super::*;
1547        use std::collections::HashMap;
1548        use std::sync::Mutex;
1549
1550        struct CallbackCtx {
1551            store: Mutex<HashMap<[u8; 32], Vec<u8>>>,
1552        }
1553
1554        unsafe extern "C" fn cb_store(
1555            ctx: *mut c_void,
1556            _uri: *const c_char,
1557            hash: *const u8,
1558            _size: u64,
1559            data: *const u8,
1560            data_len: usize,
1561        ) -> c_int {
1562            let ctx = &*(ctx as *const CallbackCtx);
1563            let mut h = [0u8; 32];
1564            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1565            let buf = if data_len == 0 {
1566                Vec::new()
1567            } else {
1568                std::slice::from_raw_parts(data, data_len).to_vec()
1569            };
1570            ctx.store.lock().unwrap().insert(h, buf);
1571            0
1572        }
1573
1574        unsafe extern "C" fn cb_fetch(
1575            ctx: *mut c_void,
1576            _uri: *const c_char,
1577            hash: *const u8,
1578            _size: u64,
1579            out_data: *mut *mut u8,
1580            out_len: *mut usize,
1581        ) -> c_int {
1582            let ctx = &*(ctx as *const CallbackCtx);
1583            let mut h = [0u8; 32];
1584            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1585            let store = ctx.store.lock().unwrap();
1586            match store.get(&h) {
1587                Some(bytes) => {
1588                    let boxed = bytes.clone().into_boxed_slice();
1589                    let len = boxed.len();
1590                    let ptr = Box::into_raw(boxed) as *mut u8;
1591                    *out_data = ptr;
1592                    *out_len = len;
1593                    0
1594                }
1595                None => NET_ERR_BLOB_NOT_FOUND,
1596            }
1597        }
1598
1599        unsafe extern "C" fn cb_fetch_range(
1600            ctx: *mut c_void,
1601            _uri: *const c_char,
1602            hash: *const u8,
1603            _size: u64,
1604            range_start: u64,
1605            range_end: u64,
1606            out_data: *mut *mut u8,
1607            out_len: *mut usize,
1608        ) -> c_int {
1609            let ctx = &*(ctx as *const CallbackCtx);
1610            let mut h = [0u8; 32];
1611            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1612            let store = ctx.store.lock().unwrap();
1613            match store.get(&h) {
1614                Some(bytes) => {
1615                    let s = range_start as usize;
1616                    let e = range_end as usize;
1617                    if s > e || e > bytes.len() {
1618                        return NET_ERR_BLOB_BACKEND;
1619                    }
1620                    let slice = bytes[s..e].to_vec().into_boxed_slice();
1621                    let len = slice.len();
1622                    *out_data = Box::into_raw(slice) as *mut u8;
1623                    *out_len = len;
1624                    0
1625                }
1626                None => NET_ERR_BLOB_NOT_FOUND,
1627            }
1628        }
1629
1630        unsafe extern "C" fn cb_exists(
1631            ctx: *mut c_void,
1632            _uri: *const c_char,
1633            hash: *const u8,
1634            _size: u64,
1635            out_exists: *mut c_int,
1636        ) -> c_int {
1637            let ctx = &*(ctx as *const CallbackCtx);
1638            let mut h = [0u8; 32];
1639            h.copy_from_slice(std::slice::from_raw_parts(hash, 32));
1640            *out_exists = if ctx.store.lock().unwrap().contains_key(&h) {
1641                1
1642            } else {
1643                0
1644            };
1645            0
1646        }
1647
1648        unsafe extern "C" fn cb_free(_ctx: *mut c_void, data: *mut u8, len: usize) {
1649            if data.is_null() {
1650                return;
1651            }
1652            let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len));
1653        }
1654
1655        #[test]
1656        fn callback_adapter_publish_resolve_round_trip() {
1657            let ctx = Box::new(CallbackCtx {
1658                store: Mutex::new(HashMap::new()),
1659            });
1660            let ctx_ptr = Box::into_raw(ctx) as *mut c_void;
1661            let vtable = NetBlobAdapterVtable {
1662                store: cb_store,
1663                fetch: cb_fetch,
1664                fetch_range: cb_fetch_range,
1665                exists: cb_exists,
1666                free_buffer: cb_free,
1667            };
1668
1669            let id_c = std::ffi::CString::new("ffi-cb-roundtrip").unwrap();
1670            let uri_c = std::ffi::CString::new("cb://round-trip").unwrap();
1671            unsafe {
1672                assert_eq!(
1673                    net_blob_register_callback_adapter(id_c.as_ptr(), &vtable, ctx_ptr),
1674                    0
1675                );
1676
1677                let payload = b"vtable round-trip payload";
1678                let mut out_buf: *mut u8 = std::ptr::null_mut();
1679                let mut out_len: usize = 0;
1680                let rc = net_blob_publish(
1681                    id_c.as_ptr(),
1682                    uri_c.as_ptr(),
1683                    payload.as_ptr(),
1684                    payload.len(),
1685                    &mut out_buf,
1686                    &mut out_len,
1687                );
1688                assert_eq!(rc, 0);
1689
1690                let mut content_buf: *mut u8 = std::ptr::null_mut();
1691                let mut content_len: usize = 0;
1692                let rc = net_blob_resolve(
1693                    id_c.as_ptr(),
1694                    out_buf,
1695                    out_len,
1696                    &mut content_buf,
1697                    &mut content_len,
1698                );
1699                assert_eq!(rc, 0);
1700                let resolved = std::slice::from_raw_parts(content_buf, content_len);
1701                assert_eq!(resolved, payload);
1702
1703                net_blob_free_buffer(out_buf, out_len);
1704                net_blob_free_buffer(content_buf, content_len);
1705                assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1706
1707                // Reclaim the leaked ctx box.
1708                drop(Box::from_raw(ctx_ptr as *mut CallbackCtx));
1709            }
1710        }
1711    }
1712
1713    #[test]
1714    fn ffi_duplicate_registration_rejected() {
1715        let id = unique_id("ffi-dup");
1716        let root = std::env::temp_dir().join(format!("net-ffi-blob-{}", id));
1717        let id_c = CString::new(id.clone()).unwrap();
1718        let root_c = CString::new(root.to_string_lossy().as_ref()).unwrap();
1719        unsafe {
1720            assert_eq!(
1721                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1722                0
1723            );
1724            assert_eq!(
1725                net_blob_register_fs_adapter(id_c.as_ptr(), root_c.as_ptr()),
1726                NET_ERR_BLOB_DUPLICATE_ID
1727            );
1728            assert_eq!(net_blob_unregister_adapter(id_c.as_ptr()), 1);
1729        }
1730        let _ = std::fs::remove_dir_all(&root);
1731    }
1732
1733    /// Regression for the `MeshBlobAdapterHandle` use-after-free /
1734    /// double-free (security audit H1). Pre-fix the handle had no
1735    /// `HandleGuard`; `_free` did an unconditional `Box::from_raw`,
1736    /// so an op racing `_free` read freed memory and a second `_free`
1737    /// was a double-free.
1738    ///
1739    /// Post-fix the box is leaked on `_free` (only the inner is
1740    /// dropped) and every op gates on `guard.try_enter()`. This makes
1741    /// two properties observable + deterministic:
1742    ///   1. An op on a freed handle bails with the null-pointer code
1743    ///      (reading the still-valid leaked guard) instead of UB.
1744    ///   2. A second `_free` is a no-op (single-winner `begin_free`),
1745    ///      not a double-free.
1746    #[cfg(all(feature = "dataforts", feature = "netdb", feature = "redex-disk"))]
1747    #[test]
1748    fn blob_adapter_ops_after_free_bail_and_double_free_is_safe() {
1749        use crate::ffi::cortex::{net_redex_free, net_redex_new};
1750
1751        let null_rc: c_int = NetError::NullPointer.into();
1752        let id_c = CString::new(unique_id("ffi-blob-adapter-uaf")).unwrap();
1753
1754        unsafe {
1755            // In-memory redex (NULL persistent_dir) → no disk needed.
1756            let redex = net_redex_new(std::ptr::null());
1757            assert!(!redex.is_null());
1758
1759            // persistent = 0 (in-memory chunks), overflow_json = NULL.
1760            let adapter = net_mesh_blob_adapter_new(redex, id_c.as_ptr(), 0, std::ptr::null());
1761            assert!(!adapter.is_null(), "adapter must construct");
1762
1763            // While live, the metrics accessors return valid results.
1764            let live = net_mesh_blob_adapter_overflow_enabled(adapter);
1765            assert!(live == 0 || live == 1, "live overflow_enabled in {{0,1}}");
1766
1767            // Free once.
1768            net_mesh_blob_adapter_free(adapter);
1769
1770            // Ops on the freed handle must bail (guard.freeing == true →
1771            // try_enter == None), NOT UAF. The leaked box keeps the
1772            // guard readable.
1773            assert_eq!(
1774                net_mesh_blob_adapter_overflow_enabled(adapter),
1775                null_rc,
1776                "op on freed handle must return the null-pointer bail code",
1777            );
1778            assert_eq!(net_mesh_blob_adapter_overflow_active(adapter), null_rc);
1779            assert!(
1780                net_mesh_blob_adapter_prometheus_text(adapter).is_null(),
1781                "ptr-returning op on freed handle must return null",
1782            );
1783            let blob_ref = [0u8; 4];
1784            assert_eq!(
1785                net_mesh_blob_adapter_store(
1786                    adapter,
1787                    blob_ref.as_ptr(),
1788                    blob_ref.len(),
1789                    std::ptr::null(),
1790                    0,
1791                ),
1792                null_rc,
1793                "store on freed handle must bail, not run against freed inner",
1794            );
1795
1796            // Double free must be safe (single-winner begin_free).
1797            net_mesh_blob_adapter_free(adapter);
1798
1799            // NULL handle is a no-op for every entry point.
1800            net_mesh_blob_adapter_free(std::ptr::null_mut());
1801            assert_eq!(
1802                net_mesh_blob_adapter_overflow_enabled(std::ptr::null()),
1803                null_rc,
1804            );
1805
1806            net_redex_free(redex);
1807        }
1808    }
1809}