dlsym 0.1.0

Memory-safe bindings around the platform's dynamic library loading primitives
Documentation

dlsym

CI crates.io docs.rs MSRV License: MIT

Bindings around the platform's dynamic library loading primitives with greatly improved memory safety. The most important safety guarantee of this library is the prevention of dangling symbols that may occur after a library is unloaded.

It comes with dlsym-gen, which turns C headers into Rust bindings (or a whole crate) that load the library at run time: no link-time dependency, no -sys crate, and no LLVM needed to build.

Loading symbols

use dlsym::Library;

// SAFETY: libm's initialisers are trusted.
let libm = unsafe { Library::open("libm.so.6")? };
// SAFETY: `cos` has this signature.
let cos = unsafe { libm.get::<unsafe extern "C" fn(f64) -> f64>(c"cos")? };
// SAFETY: `cos` is defined for every `f64`.
assert_eq!(unsafe { cos.call((0.0,)) }, 1.0);
// drop(libm) here would not compile: `cos` borrows it.
  • Symbol<'lib, T> borrows its Library, so the borrow checker rejects unloading while a symbol is in use.
  • Symbols don't Deref to the raw pointer. Functions are called with call((args,)), variadic ones with with(|f| f(..)), and the raw pointer only comes out through unsafe fn into_inner.
  • ArcSymbol<T> (from Library::get_arc) keeps the library alive through an Arc, and Library::leak gives 'static symbols, both without transmute.
  • Only unsafe extern function pointers and raw data pointers can be requested: get::<u64> and safe extern "C" fn types don't compile.
  • A symbol whose address is null (NullSymbol) is told apart from a missing one (SymbolNotFound).
  • Library::path() and AddressInfo::of(ptr) / Symbol::info() (which library, and which symbol, an address belongs to).
  • Library::open_first([...]), Library::this(), library_filename("z"), plus os::unix::OpenOptions (RTLD_LAZY/GLOBAL/NODELETE/NOLOAD/ DEEPBIND, dlvsym) and os::windows::OpenOptions (LOAD_LIBRARY_SEARCH_*, already-loaded modules; loader error dialogs are suppressed).

Binding a whole API

library! declares a struct that owns the library and every symbol, using the same syntax as a Rust 2024 unsafe extern block:

use std::ffi::{c_char, c_int};
use dlsym::Bindings;

dlsym::library! {
    pub struct LibC {
        unsafe extern "C" {
            pub safe fn abs(x: c_int) -> c_int;              // callable without `unsafe`
            pub fn strlen(s: *const c_char) -> usize;        // an `unsafe` method
            pub fn printf(fmt: *const c_char, ...) -> c_int; // variadic
            #[optional]                                      // may be missing
            pub fn strlcpy(d: *mut c_char, s: *const c_char, n: usize) -> usize;
            #[link_name = "strlen"]                          // renamed
            pub fn len(s: *const c_char) -> usize;
            pub static mut environ: *const *const c_char;    // data
        }
    }
}

// SAFETY: libc's initialisers are trusted.
let libc = unsafe { LibC::open("libc.so.6")? };
assert_eq!(libc.abs(-4), 4);
assert_eq!(unsafe { libc.strlen(c"abc".as_ptr()) }, 3);
unsafe { libc.printf().with(|f| f(c"%d\n".as_ptr(), 42)) };

Every non-#[optional] symbol is resolved when the struct is created, so a call is a constant-index load plus the call itself. The function pointers live inside the struct next to the library, so they cannot outlive it.

Generating bindings from C headers

Install the generator with cargo install dlsym --features codegen, or download a prebuilt dlsym-gen (Linux, macOS universal and Windows, for x86_64 and aarch64) from the releases page. It needs a libclang at run time: one from LLVM, Xcode, or your distribution's libclang package. It is found through --libclang, $LIBCLANG_PATH, llvm-config, or the usual install locations.

# A crate (Cargo.toml and lib.rs are only written if missing):
dlsym-gen /usr/include/zlib.h --crate zlib-dl -l libz.so.1 -l libz.1.dylib -l zlib1.dll

# Or a single module:
dlsym-gen /usr/include/foo/foo.h -o src/foo.rs --struct Foo -- -I/usr/include/foo
use zlib_dl::*;

let z = unsafe { Zlib::open_default() }?;       // tries each -l name in turn
let mut strm = z_stream::default();             // zeroed, like C's `= {0}`
let rc = unsafe { z.deflateInit(&mut strm, Z_BEST_COMPRESSION) }; // the C macro
assert_eq!(rc, Z_OK);                           // #defines become constants

What it handles:

  • Functions (variadic, calling conventions), global variables, structs and unions (packed, aligned, #pragma pack, bitfields with typed accessors, C11 anonymous members, flexible array members), enums (as newtypes, so an unexpected value from C is never undefined behaviour), typedefs and function pointers.
  • Object-like #defines, evaluated by clang: integers, floats, strings and constant expressions. Function-like macros that wrap a single call (zlib's deflateInit(strm, level)) become methods too.
  • Owned handles: --owned gzFile=gzclose,gzopen generates a GzFile<'lib> that calls gzclose when dropped, and a gzopen_owned constructor. The handle borrows the bindings, so it cannot leak, be closed twice, or outlive the library. Functions taking the handle first become its methods (f.gzwrite(..), or h.get() for demo_handle_get(h)), and --safe-handle gzeof makes a method safe while the raw function, which would accept any pointer, stays unsafe.
  • Struct layouts are checked at compile time against the C compiler's (size_of, align_of, offset_of!). --target generates for another platform; bindings built for the wrong one fail to compile.
  • It binds what the given headers declare, plus the headers they include with #include "..." (--header-dir adds more). Types from elsewhere are pulled in as needed: in full when used by value, opaque when only used behind pointers.
  • Headers often declare functions that only some builds of a library export (sqlite's Windows-only functions, optional features). With -l, the generator opens the installed library and marks whatever it lacks #[optional], so the bindings still load; calling such a function returns None.
  • --allow/--block filter by name, --optional marks more symbols that may be missing, and --safe declares functions callable without unsafe. Unsupported items (e.g. long double) are skipped with a warning.
  • Tested on zlib, sqlite3, curl, libpng, xz, bzip2, zstd, expat, libarchive, libmagic, ogg, opus, libsodium, libuv, libyaml, lz4, pcre2, SDL2, Xlib, OpenGL, EGL, Vulkan, OpenSSL, libxml2, GLib, D-Bus, cairo, FreeType, ALSA, PulseAudio, ncurses, readline, libpcap, libusb, libudev, libevent, libjpeg, libtiff, libwebp, fontconfig, gdbm and libuuid: every generated module compiles.

Typed wrappers turn C strings, (pointer, length) pairs, out-parameters and error codes into Rust types. Each annotated function keeps its raw method and gets a _rs one next to it (handle methods use the annotations directly):

dlsym-gen sqlite3.h -l libsqlite3.so.0 --owned sqlite3=sqlite3_close \
    --wrap 'sqlite3_open:str,out=ppDb,ret=nonzero,safe' \
    --wrap 'sqlite3_exec:str=sql,ret=nonzero' \
    --wrap 'sqlite3_libversion:ret=static-str,safe'
dlsym-gen zlib.h -l libz.so.1 --owned gzFile=gzclose,gzopen \
    --wrap gzopen:str --wrap 'gzwrite:slice=buf/len,ret=negative,safe'
let mut db = s.sqlite3_open_rs(c":memory:")?;          // Result<Sqlite3Owned, c_int>
unsafe { db.exec(c"create table t(x)", None, null_mut(), null_mut()) }?;
let v = s.sqlite3_libversion_rs();                     // Option<&CStr>, safe
// `db` is closed when dropped, and so is the handle sqlite3_open returns
// on failure.
let mut f = unsafe { z.gzopen_owned(c"out.gz", c"wb") }.unwrap();
f.gzwrite(b"hello")?;                                  // &[u8] in, Result out

str? makes strings nullable (Option<&CStr>). out= of an --owned handle type returns the handle's RAII wrapper. safe makes the wrapper callable without unsafe: it is the author's assertion that the function is sound for any values of the remaining parameters. ret=errno reads errno right after the call.

Long option lists can live in a file, passed as @zlib.args (whitespace- separated arguments, "quotes", # comments).

C++ headers (--cpp, automatic for .hpp/.hh/.hxx) work for the parts with a C-like ABI: functions and variables in namespaces and public static members are bound through the mangled names clang computes for the target (geo::add(int, int) becomes geo_add_int_int when overloaded), references become pointers, and functions not declared noexcept use extern "C-unwind". Non-virtual member functions take this first, and a class with public constructors and a destructor gets an RAII wrapper: Rust allocates the object, constructs it in place, never moves it, and destroys and frees it on drop, with the methods on the wrapper:

let mut h = unsafe { HunspellObject::new(&lib, aff.as_ptr(), dic.as_ptr(), null()) };
let ok = unsafe { h.spell_const_char_ptr_int_ptr_char_ptr_ptr(word.as_ptr(), null_mut(), null_mut()) };
// `~Hunspell()` runs and the memory is freed here.

Classes that are not trivially copyable keep their size but not their fields, and are never passed by value. Virtual functions are not bound.

From a build.rs, use dlsym::codegen::Builder (see the codegen docs).

Static, libc-free binaries (fullrust)

On the fullrust target (x86_64-unknown-linux-fullrust), which builds Rust programs with no libc and no dynamic loader, dlsym depends on dldota and loads libraries through the system's own ld.so, booted on first use. The same code works unchanged; function symbols resolve to trampolines that switch to the system libc's thread pointer around each call. Host callbacks passed to foreign code must be wrapped with dlsym::os::fullrust::context()?.callback(..), and AddressInfo and Library::path are unavailable. See src/os/fullrust.rs (docs.rs does not build that target).

Compared to libloading

libloading dlsym
Symbols borrow their library ✓ ✓
Raw pointer copyable out of a symbol via *sym only unsafe
'static symbols without transmute ArcSymbol, leak
Symbol type checked to be a pointer ✓
Whole-API structs (library!) ✓
Header → bindings generator ✓

See ROADMAP.md for what's next.

License

MIT