feat: improve cstr_ffi module

This commit is contained in:
2026-07-29 21:30:09 +08:00
parent 5ba64953bb
commit eba2c8e6e0
2 changed files with 656 additions and 42 deletions
+436 -42
View File
@@ -1,19 +1,59 @@
//! When calling this dynamic library with outside programs,
//! outer programs may usually need to fetch string resource produced by Rust code.
//! However it is impossible pass Rust string directly to outer program.
//! When calling Rust exported dynamic library with outside programs,
//! outer programs may usually need to fetch string resource produced by Rust code,
//! or push unchecked string resource to Rust code.
//! However it is impossible to use Rust string directly to outer program.
//!
//! This module provide **thread independent** string cache for resolving this issue.
//! When we need pass string to outer programs, we push that string into this string as C-like format,
//! then return its pointer to outer program.
//! So that outside program can utilize it like calling C/C++ library.
//! The only thing that outer programs should note is that this string is volatile,
//! once they get it, they must dupliate it immediately before any futher calling to this dynamic library.
//! This module provides **thread independent** facilities for two directions:
//!
//! - Output (Rust -> C/C++): push strings / string vectors / string views into a thread-local
//! cache (or re-expose caller-owned memory for views) and hand stable pointers out.
//! - Input (C/C++ -> Rust): parse foreign-provided strings into borrowed `&str` without copying.
//!
//! # Output slots
//!
//! There are three independent kinds of output slots. A single FFI call may produce any number of
//! each, in any combination:
//!
//! - **Single NUL-terminated string** via [`push_ffi_string`]. The string is *copied* into the
//! cache; a stable [`CStrPtr`] is returned.
//! - **String vector** (a list whose entries are NUL-terminated strings) via
//! [`push_ffi_string_vec`] (nullptr-terminated list) or [`push_ffi_string_vec_with_len`]
//! (count-terminated list). Entries are *copied* into the cache.
//! - **String view** via [`push_ffi_string_view`] (single) or
//! [`push_ffi_string_view_vec_with_len`] (vector of views). These are length-delimited and do
//! **not** copy the string data; see "String view ownership" below.
//!
//! All cached slots stay valid until the next [`clear_ffi_strings`] call. Outer programs must
//! duplicate any retrieved pointer immediately before issuing the next call into the library.
//!
//! # String view ownership
//!
//! Please read this chapter CAREFULLY!
//!
//! Functions in the `*_string_view*` family are designed exclusively for transmitting very long
//! strings. To avoid an expensive copy they do **not** take ownership of and do **not** copy the
//! string data. They only re-expose a pointer plus length into memory that the *caller* owns.
//!
//! This deliberately steps outside ordinary Rust lifetime guarantees and is extremely easy to
//! misuse. The caller MUST guarantee that the underlying storage outlives every read performed by
//! the foreign (C/C++) side. If the source string is dropped or moved before the foreign side
//! finishes reading, the foreign side receives a dangling pointer and the behavior is undefined.
//!
//! It is highly suggested that use `SAFETY` annotation for this use case.
use std::cell::RefCell;
use std::ffi::{c_char, CStr, CString};
use std::ffi::{CStr, CString, c_char};
use std::slice;
use thiserror::Error as TeError;
/// The type representing the raw pointer to immutable C-style NUL-terminated string.
pub type CStyleString = *const c_char;
/// The type representing a raw pointer to an immutable C/C++ character string.
///
/// This is a plain string-pointer alias used uniformly across the module. Whether the pointee is
/// NUL-terminated or length-delimited depends on the specific function it is passed to or returned
/// from; consult each function's documentation for the expected convention.
pub type CStrPtr = *const c_char;
/// The type representing a pointer to a vector (array) of [`CStrPtr`] entries.
pub type CStrVecPtr = *const CStrPtr;
// region: Error
@@ -34,64 +74,299 @@ type Result<T> = std::result::Result<T, Error>;
// endregion
// region: String View Type
/// A `#[repr(C)]` mirror of C++ `std::string_view`, exposed so that a *vector of string views*
/// can be returned to the foreign side as one contiguous array.
///
/// A single string view is passed across the FFI boundary as two independent parameters
/// (`ptr` and `len`); this struct exists solely so that many views can be packed into one array
/// for [`push_ffi_string_view_vec_with_len`] / [`parse_ffi_string_view_vec_with_len`].
///
/// # Ownership
///
/// `ptr` does **not** point into memory owned by this crate. When produced by
/// [`push_ffi_string_view_vec_with_len`] it points into caller-owned memory; see the module-level
/// "String view ownership" section.
#[repr(C)]
pub struct CStringView {
/// Pointer to the first byte of the viewed string. Not NUL-terminated in general and may
/// contain interior NUL bytes.
pub ptr: CStrPtr,
/// Length in bytes of the viewed string.
pub len: usize,
}
/// The type representing a pointer to a vector (array) of [`CStringView`] entries.
pub type CStrViewVecPtr = *const CStringView;
// endregion
// region: String Cache for Exposing
/// One independent string-vector slot.
///
/// `items` owns the actual `CString` data, each individually boxed so the pointers stay stable
/// even when `items` grows. `ptrs` is the parallel pointer array consumed by C/C++; for the
/// nullptr-terminated variant a trailing null pointer is appended to `ptrs`.
struct CStringVec {
items: Vec<Box<CString>>,
ptrs: Vec<CStrPtr>,
}
impl CStringVec {
/// Build a string vector from the given entries.
///
/// A trailing null entry is always appended to the pointer array. This single layout serves
/// both consumers: the nullptr-terminated form scans until the null, and the count-terminated
/// form simply reads [`CStringVec::item_count`] entries and ignores the trailing null.
///
/// Capacity arithmetic uses checked addition because reserving `items.len() + 1` slots could
/// in theory overflow on an absurdly large input; such a case is treated as unrecoverable.
fn new(items: &[&str]) -> Result<Self> {
let ptrs_cap = items
.len()
.checked_add(1)
.expect("CStringVec capacity overflow");
let mut obj = Self {
items: Vec::with_capacity(items.len()),
ptrs: Vec::with_capacity(ptrs_cap),
};
for s in items {
let boxed = Box::new(CString::new(*s)?);
obj.ptrs.push(boxed.as_ptr());
obj.items.push(boxed);
}
obj.ptrs.push(std::ptr::null());
Ok(obj)
}
/// Return the head pointer of the parallel pointer array, for consumption by C/C++.
fn head_ptr(&self) -> CStrVecPtr {
self.ptrs.as_ptr()
}
/// Return the number of real string entries (excludes any trailing null terminator).
fn item_count(&self) -> usize {
self.items.len()
}
}
/// One independent string-view-vector slot.
///
/// Only the *container* (`views`) is owned by the cache; each [`CStringView::ptr`] inside points
/// into caller-owned memory and is not managed here.
struct CStringViewVec {
views: Vec<CStringView>,
}
impl CStringViewVec {
/// Build a string-view vector that re-exposes the caller-owned input strings without
/// copying their data.
fn new(items: &[&str]) -> Self {
let views: Vec<CStringView> = items
.iter()
.map(|s| CStringView {
ptr: s.as_ptr() as CStrPtr,
len: s.len(),
})
.collect();
Self { views }
}
/// Return the head pointer of the view array, for consumption by C/C++.
fn head_ptr(&self) -> CStrViewVecPtr {
self.views.as_ptr()
}
/// Return the number of view entries.
fn item_count(&self) -> usize {
self.views.len()
}
}
struct StringCache {
msg: CString,
strings: Vec<Box<CString>>,
string_vecs: Vec<Box<CStringVec>>,
string_view_vecs: Vec<Box<CStringViewVec>>,
}
impl StringCache {
fn new() -> Self {
Self {
msg: CString::new("").expect("empty string must be valid for CString"),
strings: Vec::new(),
string_vecs: Vec::new(),
string_view_vecs: Vec::new(),
}
}
pub fn set_msg(&mut self, msg: &str) -> Result<()> {
self.msg = CString::new(msg)?;
Ok(())
/// Drop every slot held by this cache.
///
/// For string-view-vector slots only the *container* is dropped; the caller-owned string data
/// behind each [`CStringView::ptr`] is not (and cannot be) touched here.
fn clear(&mut self) {
self.strings.clear();
self.string_vecs.clear();
self.string_view_vecs.clear();
}
pub fn get_msg(&self) -> CStyleString {
self.msg.as_ptr()
/// Store one boxed NUL-terminated string and return its stable pointer.
fn push_string(&mut self, s: Box<CString>) -> CStrPtr {
let ptr = s.as_ptr();
self.strings.push(s);
ptr
}
pub fn clear_msg(&mut self) {
self.msg = CString::new("").expect("empty string must be valid for CString");
/// Store one boxed string-vector slot.
fn push_string_vec(&mut self, sv: Box<CStringVec>) {
self.string_vecs.push(sv);
}
/// Store one boxed string-view-vector slot.
fn push_string_view_vec(&mut self, svv: Box<CStringViewVec>) {
self.string_view_vecs.push(svv);
}
}
// endregion
// region: Exposed Functions
// region: Output Slots Functions
thread_local! {
static STRING_CACHE: RefCell<StringCache> = RefCell::new(StringCache::new());
}
/// Set thread local string exposed for C code.
pub fn set_ffi_string(msg: &str) -> Result<()> {
STRING_CACHE.with(|e| {
e.borrow_mut().set_msg(msg)
})
}
/// Get const pointer to thread local string exposed for C code.
pub fn get_ffi_string() -> CStyleString {
STRING_CACHE.with(|e| e.borrow().get_msg())
}
/// Clear thread local string exposed for C code.
/// Clear every single-string, string-vector and string-view-vector slot stored for the current
/// thread.
///
/// This function usually should be called at the beginning of every exposed C functions.
pub fn clear_ffi_string() {
STRING_CACHE.with(|e| {
e.borrow_mut().clear_msg();
});
/// This function usually should be called at the beginning of every exposed C function so that
/// each FFI call starts from an empty output cache.
///
/// Note: clearing a string-view-vector slot only drops the *container* held by this crate; the
/// caller-owned string data behind each [`CStringView::ptr`] is not (and cannot be) touched here.
pub fn clear_ffi_strings() {
STRING_CACHE.with(|c| c.borrow_mut().clear());
}
/// Parse string given by C code into Rust string.
pub fn parse_ffi_string<'a>(ptr: CStyleString) -> Result<&'a str> {
/// Push one independent NUL-terminated string into the thread-local cache and return a stable
/// pointer to it.
///
/// The string is **copied** into the cache. The returned pointer stays valid until the next
/// [`clear_ffi_strings`] call; the foreign side must duplicate it before the next FFI call.
///
/// # Errors
///
/// Returns an error if `s` contains an interior NUL byte.
pub fn push_ffi_string(s: &str) -> Result<CStrPtr> {
let boxed = Box::new(CString::new(s)?);
STRING_CACHE.with(|c| Ok(c.borrow_mut().push_string(boxed)))
}
/// Push one independent string vector (NUL-terminated entries, **nullptr-terminated list**) into
/// the thread-local cache and return a stable pointer to the pointer array.
///
/// The returned array has `items.len() + 1` entries whose final entry is a null pointer acting as
/// the list terminator. The foreign side scans forward until it meets the null entry. An empty
/// `items` yields a one-entry array containing only the terminating null (a valid empty list).
///
/// All string contents are copied into the cache and stay valid until the next
/// [`clear_ffi_strings`] call.
///
/// # Errors
///
/// Returns an error if any entry contains an interior NUL byte.
pub fn push_ffi_string_vec(items: &[&str]) -> Result<CStrVecPtr> {
let sv = Box::new(CStringVec::new(items)?);
let head = sv.head_ptr();
STRING_CACHE.with(|c| c.borrow_mut().push_string_vec(sv));
Ok(head)
}
/// Push one independent string vector (NUL-terminated entries, **count-terminated list**) into the
/// thread-local cache and return a stable pointer to the pointer array together with the entry
/// count.
///
/// The returned array contains exactly `count` entries for the foreign side to read (plus an
/// internal trailing null terminator that is ignored). The foreign side must rely on the returned
/// count instead of scanning for a null. The returned pointer is always valid: the trailing null
/// guarantees a non-empty allocation, so even when `count` is 0 the pointer is non-dangling.
///
/// All string contents are copied into the cache and stay valid until the next
/// [`clear_ffi_strings`] call.
///
/// # Errors
///
/// Returns an error if any entry contains an interior NUL byte.
pub fn push_ffi_string_vec_with_len(items: &[&str]) -> Result<(CStrVecPtr, usize)> {
let sv = Box::new(CStringVec::new(items)?);
let head = sv.head_ptr();
let count = sv.item_count();
STRING_CACHE.with(|c| c.borrow_mut().push_string_vec(sv));
Ok((head, count))
}
/// Re-expose a single string as a `(ptr, len)` pair **without copying and without storing anything
/// in the cache**.
///
/// This is intended exclusively for very long strings. The returned pointer points directly into
/// the memory of `s`; no data is duplicated and no slot is created in the cache.
///
/// # Ownership / Lifetime -- READ CAREFULLY
///
/// The caller owns the underlying storage and **must** keep it alive -- unmoved and undropped --
/// for as long as the foreign (C/C++) side keeps reading through the returned pointer. If `s`
/// (or the buffer backing it) is released before the foreign side finishes reading, the foreign
/// side observes a dangling pointer and the behavior is undefined. This crate does not and cannot
/// enforce this constraint.
///
/// Because the result is length-delimited, `s` is permitted to contain interior NUL bytes; no
/// scanning is performed. This function cannot fail and returns no `Result`.
pub fn push_ffi_string_view(s: &str) -> (CStrPtr, usize) {
(s.as_ptr() as CStrPtr, s.len())
}
/// Build a vector of [`CStringView`] over the given strings and return a stable pointer to that
/// array together with the entry count.
///
/// Only the *container* array is stored in the cache (so the returned [`CStrViewVecPtr`] stays
/// valid until the next [`clear_ffi_strings`] call); the string data behind each
/// [`CStringView::ptr`] is **not** copied and **not** owned by this crate.
///
/// # Ownership / Lifetime -- READ CAREFULLY
///
/// Each entry's `ptr` points directly into the memory of the corresponding input `&str`. The
/// caller **must** keep every source string alive -- unmoved and undropped -- for as long as the
/// foreign (C/C++) side reads through the array. If any source string is released before the
/// foreign side finishes, the foreign side observes dangling pointers and the behavior is
/// undefined. This crate does not and cannot enforce this constraint.
///
/// Because each entry is length-delimited, the inputs are permitted to contain interior NUL
/// bytes; no scanning is performed. This function cannot fail and returns no `Result`.
///
/// When `count` is 0, the returned pointer may be a dangling pointer valid only for zero-sized
/// reads (per `Vec::as_ptr` for an empty allocation). The foreign side must drive consumption off
/// `count` and must not dereference the pointer when `count` is 0.
pub fn push_ffi_string_view_vec_with_len(items: &[&str]) -> (CStrViewVecPtr, usize) {
let svv = Box::new(CStringViewVec::new(items));
let head = svv.head_ptr();
let count = svv.item_count();
STRING_CACHE.with(|c| c.borrow_mut().push_string_view_vec(svv));
(head, count)
}
// endregion
// region: Input Slots Functions
/// Parse a NUL-terminated C/C++ string into a borrowed Rust `&str`.
///
/// The returned slice borrows the foreign memory; no allocation is performed. It is only valid
/// while the caller keeps the original foreign buffer alive.
///
/// # Errors
///
/// Returns an error if `ptr` is null or if the content is not valid UTF-8.
pub fn parse_ffi_string<'a>(ptr: CStrPtr) -> Result<&'a str> {
if ptr.is_null() {
Err(Error::NullPtr)
} else {
@@ -100,4 +375,123 @@ pub fn parse_ffi_string<'a>(ptr: CStyleString) -> Result<&'a str> {
}
}
/// Parse a **nullptr-terminated** vector of NUL-terminated C/C++ strings into borrowed Rust
/// `&str`s.
///
/// `ptrs` points at an array of [`CStrPtr`] terminated by a null entry. The function scans
/// forward until it meets the null terminator. The returned slices borrow the foreign memory and
/// are only valid while the caller keeps the original buffers alive. No allocation is performed
/// for the string contents themselves (a `Vec` of references is returned).
///
/// # Errors
///
/// Returns an error if `ptrs` is null or if any entry is not valid UTF-8.
pub fn parse_ffi_string_vec<'a>(ptrs: CStrVecPtr) -> Result<Vec<&'a str>> {
if ptrs.is_null() {
return Err(Error::NullPtr);
}
let mut out = Vec::new();
let mut idx = 0usize;
loop {
let entry = unsafe { *ptrs.add(idx) };
if entry.is_null() {
break;
}
out.push(parse_ffi_string(entry)?);
idx += 1;
}
Ok(out)
}
/// Parse a **count-terminated** vector of NUL-terminated C/C++ strings into borrowed Rust `&str`s.
///
/// `ptrs` points at an array of exactly `count` [`CStrPtr`] entries (no terminator). The returned
/// slices borrow the foreign memory and are only valid while the caller keeps the original buffers
/// alive. No allocation is performed for the string contents themselves (a `Vec` of references is
/// returned).
///
/// # Errors
///
/// Returns an error if `ptrs` is null while `count > 0`, or if any entry is not valid UTF-8. When
/// `count` is 0, a null `ptrs` is accepted and an empty vector is returned.
pub fn parse_ffi_string_vec_with_len<'a>(
ptrs: CStrVecPtr,
count: usize,
) -> Result<Vec<&'a str>> {
if count == 0 {
return Ok(Vec::new());
}
if ptrs.is_null() {
return Err(Error::NullPtr);
}
let mut out = Vec::with_capacity(count);
for idx in 0..count {
let entry = unsafe { *ptrs.add(idx) };
out.push(parse_ffi_string(entry)?);
}
Ok(out)
}
/// Parse a length-delimited string view (C++ `std::string_view` semantics) given as an independent
/// pointer and length into a borrowed Rust `&str`.
///
/// Unlike [`parse_ffi_string`], the buffer is not required to be NUL-terminated, so interior NUL
/// bytes are permitted. A default-constructed C++ `std::string_view` (null pointer, zero length)
/// is interpreted as an empty string.
///
/// # Lifetime
///
/// The returned slice borrows the foreign memory and is only valid while the caller keeps the
/// original foreign buffer alive. No allocation is performed.
///
/// # Errors
///
/// Returns an error if `ptr` is null while `len > 0`, or if the content is not valid UTF-8.
pub fn parse_ffi_string_view<'a>(ptr: CStrPtr, len: usize) -> Result<&'a str> {
if ptr.is_null() {
return if len == 0 {
Ok("")
} else {
Err(Error::NullPtr)
};
}
let bytes = unsafe { slice::from_raw_parts(ptr as *const u8, len) };
Ok(std::str::from_utf8(bytes)?)
}
/// Parse a **count-terminated** vector of [`CStringView`] entries into borrowed Rust `&str`s.
///
/// `views` points at an array of exactly `count` [`CStringView`] entries. Each entry is treated
/// with `std::string_view` semantics (pointer + length, no NUL requirement, interior NUL
/// permitted). An entry with a null pointer and zero length is interpreted as an empty string.
///
/// # Lifetime
///
/// The returned slices borrow the foreign memory behind each view and are only valid while the
/// caller keeps the original buffers alive. No allocation is performed for the string contents
/// themselves (a `Vec` of references is returned).
///
/// # Errors
///
/// Returns an error if `views` is null while `count > 0`, if any entry has a null pointer with
/// non-zero length, or if any entry is not valid UTF-8. When `count` is 0, a null `views` is
/// accepted and an empty vector is returned.
pub fn parse_ffi_string_view_vec_with_len<'a>(
views: CStrViewVecPtr,
count: usize,
) -> Result<Vec<&'a str>> {
if count == 0 {
return Ok(Vec::new());
}
if views.is_null() {
return Err(Error::NullPtr);
}
let mut out = Vec::with_capacity(count);
for idx in 0..count {
let view = unsafe { &*views.add(idx) };
out.push(parse_ffi_string_view(view.ptr, view.len)?);
}
Ok(out)
}
// endregion