A tour of the Rust and C++ interoperability ecosystem


Mar 22, 2022
by Louis
Categories: Development -
Tags: Rust - C++ -




Rust is a programming language with a very interesting value proposition when coming from C++, and so it is only natural to see increasing usage of it in REVEN’s codebase.

We have some internal tooling using the language, the frontend for our Windbg integration is written in Rust, and REVEN version 2.9 introduced Rust in our backend.

Integrating Rust inside of a C++ codebase does present its challenges though: while both languages can talk to the other through the C lingua franca, this common language is poor in abstractions and fraught with perils. Both C++ and Rust provide similar higher-level abstractions and facilities, such as destructors, generic types, member functions, standard strings, standard collections or iterators.

In this article, I will present two practical use cases of C++ <-> Rust interoperability, and some of the tooling that’s available in the ecosystem:

  1. Binding a C++ library to make it available to Rust (C++ -> Rust interop). I made experimental bindings for several of our open-source libraries, such as rvnmetadata and rvnsqlite. These were made in order to experiment with what’s available in the interop ecosystem and assess its viability.
  2. Integrating a Rust crate inside of our C++ backend (Rust -> C++ interop). This direction seems much rarer from reading online discussions, which is surprising to me because Rust has an interesting ecosystem of crates and it frequently makes sense to tap into it. We integrated the pdb and symbolic crates (we were even able to contribute back a bit to the latter, and to its dependencies ).

C++ -> Rust: binding C++ libraries

In the following sections, we’ll see four possible approaches to bind C++ in Rust, each with their pros and cons.

bindgen: automagic bindings

With more than 250 contributors and 100 releases, bindgen is the oldest and most used C++ <-> Rust interop tool and lives inside of the rust-lang organization on GitHub. It is led by Emilio Cobos Álvarez who works at Mozilla on Gecko and Servo.

bindgen automatically generates Rust FFI bindings by parsing C or C++ headers with libclang, and yes it can actually do C++ in some capacity too! When binding C libraries, bindgen is certainly the one tool you will need!

For C++ though, my experience is a bit more mixed, due to several causes:

  • bindgen is unable to bind large swaths of the standard library (including eg std::string ).
  • bindgen does not attempt to translate high-level C++ concepts to high-level Rust concepts: it doesn’t handle exceptions, nor a lot of the templates or automatically calling destructors.
  • bindgen has some hard to fix soundness issues .

Still, when bindgen works for you, it feels really 🌈magical🌈: it generates the full Rust API from the C++ headers, complete with code comments and all enum values. bindgen is rather unique in that it is able to do so without you having to write any kind of glue code.

Here’s how you’d generate the bindings for rvnsqlite using bindgen:

// build.rs
use bindgen;
use cmake;
use std::{env, path::PathBuf};

fn main() {
    // Builds the project in the directory located in `rvnsqlite`, installing it
    // into $OUT_DIR
    let mut config = cmake::Config::new("rvnsqlite");
    // This prevents removal of some functions we'd like to bind
    let dst = config.cxxflag("-fkeep-inline-functions").build();

    println!("cargo:rustc-link-search=native={}/lib", dst.display());
    println!("cargo:rustc-link-lib=static=rvnsqlite");
    println!("cargo:rustc-link-lib=stdc++");
    println!("cargo:rustc-link-lib=sqlite3");

    // Tell cargo to invalidate the built crate whenever the wrapper changes
    println!("cargo:rerun-if-changed=wrapper.h");

    // The bindgen::Builder is the main entry point
    // to bindgen, and lets you build up options for
    // the resulting bindings.
    let bindings = bindgen::Builder::default()
        // The input header we would like to generate
        // bindings for.
        .header("wrapper.h")
        .enable_cxx_namespaces()
        // enable C++
        .clang_args(&["-x", "c++", "--std=c++14", "-fkeep-inline-functions"])
        .opaque_type("std::.*")
        .generate_inline_functions(true)
        .whitelist_type("reven::sqlite::ResourceDatabase")
        // Tell cargo to invalidate the built crate whenever any of the
        // included header files changed.
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        // Finish the builder and generate the bindings.
        .generate()
        // Unwrap the Result and panic on failure.
        .expect("Unable to generate bindings");

    // Write the bindings to the $OUT_DIR/bindings.rs file.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

// src/lib.rs
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

// Re-export the generated bindings
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
// Allows bindgen to find the relevant headers we want to generate bindings for.
#include "rvnsqlite/include/sqlite.h"
#include "rvnsqlite/include/resource_database.h"

And a screenshot of the resulting generated documentation.

Generated documentation for `rvnsqlite-sys`

(note that the \brief commands come from doxygen commands in the source C++ code documentation)

As you can see from the numerous ⚠️ warning signs beside function names, the various generated functions are all unsafe to call. After all, bindgen is generating extern functions from a header and hoping to find the same functions with the correct signatures in the linked C++ library. Therefore calling this function has to be unsafe (the fact that C++ has no language-level distinction between safe and unsafe code is a second reason).

What you cannot see from these screenshots is that the C++ string type used by bindgen is actually [u64; 4], which, while not false (once you gloss other the relocation issues), is not especially that convenient either.

cpp!

cpp! is a macro from the cpp crate, that allows to write C++ code inline into your Rust code. Sounds crazy? It’s actually very useable! Primarily developed by Nika Layzell (mystor) and Olivier Goffart (ogoffart), this crate is used extensively in e.g. qmetaobject (a crate to build Qt/QML applications with Rust).

Compared with bindgen, cpp! both provides more control over the generation, and model higher-level C++ concepts: for example, the companion macro cpp_class! allows to wrap a C++ class with a copy constructor and a destructor in a Rust struct that accordingly implements Clone and Drop. This remains finicky, because that C++ class must be relocatable, e.g. it must be movable in memory using memcpy. Failing that, the generated code will cause soundness issues (the fundamental mismatch here is that in Rust, all value types are movable with a simple memcpy). Notably this disallows binding std::string that contains a small-string optimization in many implementations.

The drawback of having more control is that one has to write “glue-code”, mostly in the form of cpp_class! to wrap C++ types (often adding std::unique_ptr in the process). Furthermore, the lack of facilities to handle e.g. strings mean that we are often juggling with CString and the result is very unsafe. The experience of writing inline C++ inside Rust code is very empowering, but I would still recommend this crate to developers who know both C++ and Rust very well, and didn’t miss their coffee this morning.

(👀 if you got addicted to writing another language inline in Rust source files, Mara Bos released the inline-python crate, that does exactly what is written on the tin. Use at your sole discretion.)

Here is what it looks like to bind some parts of rvnsqlite using cpp!:

// build.rs: this part doesn't change a lot, the bindgen generator is replaced by cpp_build,
// and we are expecting to find the native libraries already installed (with their headers) in TETRANE_OUT_DIR
use std::path::PathBuf;

fn link_rvnsqlite(root_dir: &str) {
    println!("cargo:rustc-link-search=native={}/lib", root_dir);
    println!("cargo:rustc-link-lib=static=rvnsqlite");
    println!("cargo:rustc-link-lib=stdc++");
    println!("cargo:rustc-link-lib=sqlite3");
}

fn link_rvnbinresource(root_dir: &str) {
    println!("cargo:rustc-link-search=native={}/lib", root_dir);
    println!("cargo:rustc-link-lib=static=rvnbinresource");
    println!("cargo:rustc-link-lib=stdc++");
    println!("cargo:rustc-link-lib=boost_system");
    println!("cargo:rustc-link-lib=boost_filesystem");
}

fn link_rvnjsonresource(root_dir: &str) {
    println!("cargo:rustc-link-search=native={}/lib", root_dir);
    println!("cargo:rustc-link-lib=static=rvnjsonresource");
    println!("cargo:rustc-link-lib=stdc++");
    println!("cargo:rustc-link-lib=boost_system");
    println!("cargo:rustc-link-lib=boost_filesystem");
}

fn link_rvnmetadata(root_dir: &str) {
    link_rvnsqlite(root_dir);
    link_rvnbinresource(root_dir);
    link_rvnjsonresource(root_dir);

    println!("cargo:rustc-link-search=native={}/lib", root_dir);
    println!("cargo:rustc-link-lib=static=rvnmetadata");
    println!("cargo:rustc-link-lib=stdc++");
    println!("cargo:rustc-link-lib=pthread");
    println!("cargo:rustc-link-lib=boost_system");
    println!("cargo:rustc-link-lib=boost_filesystem");
    println!("cargo:rustc-link-lib=magic");
}

fn generate_rvnsqlite_bindings(out_dir: &str) {
    let path: PathBuf = PathBuf::from(out_dir);
    let path = path.join("include");

    cpp_build::Config::new()
        .flag("-std=c++14")
        .include(path)
        .build("src/lib.rs");
    println!("cargo:rerun-if-changed=src/lib.rs");
}

fn main() {
    let path = std::env::var("TETRANE_OUT_DIR")
        .expect("Please set TETRANE_OUT_DIR env variable to output dir of an octopus build");
    link_rvnmetadata(&path);
    generate_rvnsqlite_bindings(&path);
}
// src/lib.rs
use std::{
    ffi::CStr, // we will need to juggle these a lot
};

use cpp::{cpp, cpp_class};

// start by including the C++ we'll need
cpp! { {
    #include <rvnmetadata/metadata.h>
    #include <rvnsqlite/resource_database.h>
    #include <experimental/string_view>
    #include <memory>
} }

// define a C++ struct that's essentially a unique_ptr of our opaque struct.
cpp! { {
    struct BoxResourceDatabase {
        using Ptr = std::unique_ptr<reven::sqlite::ResourceDatabase>;
        Ptr value;

        BoxResourceDatabase(Ptr value_) : value(std::move(value_)) {}
    };
} }

// wrap the struct in a Rust struct. The `as` is used to specify its C++ name (declared in the struct above)
// this struct will automatically implement Drop to call the destructor of `BoxResourceDatabase`
cpp_class!(unsafe struct BoxResourceDatabase as "BoxResourceDatabase");

pub struct ResourceDatabase(BoxResourceDatabase);

#[derive(Debug)]
pub enum OpenMode {
    ReadOnly,
    ReadWrite,
}

impl ResourceDatabase {
    pub fn open(filename: &CStr, mode: OpenMode) -> Self {
        let filename = filename.as_ptr();
        let read_only = matches!(mode, OpenMode::ReadOnly);
        // Call the foreign C++ functions.
        // Because these functions exist in the included headers above and will be linked "natively" using a C++
        // compiler, there is no risk of mismatching signature like with bindgen.
        // It is still unsafe because obviously bad stuff can happen (👀 like dereferencing a null ptr...)
        let box_db = cpp!(unsafe [filename as "const char*", read_only as "bool"] -> BoxResourceDatabase as "BoxResourceDatabase" {
            try {
                return std::make_unique<reven::sqlite::ResourceDatabase>(reven::sqlite::ResourceDatabase::open(filename, read_only));
            } catch (reven::sqlite::DatabaseError&) {
                return {nullptr};
            } catch (reven::sqlite::ReadMetadataError&) {
                return {nullptr};
            }
        });
        // FIXME: check nullity of ptr, error handling
        Self(box_db)
    }

    pub fn create(filename: &CStr, metadata: Metadata) -> Self {
        let filename = filename.as_ptr();
        let metadata = metadata.as_opaque_metadata();
        let box_db = cpp!(unsafe [filename as "const char*", metadata as "const reven::metadata::Metadata*"] -> BoxResourceDatabase as "BoxResourceDatabase" {
            try {
                return std::make_unique<reven::sqlite::ResourceDatabase>(reven::sqlite::ResourceDatabase::create(filename, metadata->to_sqlite_raw_metadata()));
            } catch (reven::sqlite::DatabaseError&) {
                return {nullptr};
            } catch (reven::sqlite::WriteMetadataError&) {
                return {nullptr};
            }
        });
        // FIXME: check nullity of ptr, error handling
        Self(box_db)
    }

    /* ... [do useful stuff with DB] ...*/
}

cxx

Last, but not least, cxx is a crate by David Tolnay, of serde and syn fame1. It proposes a different approach to C++ <-> Rust interoperability, where instead of parsing C++ headers or making you write inline C++, it makes you write a specific Rust module called a “bridge” where you declare the interface (common types and functions). Here is an example of such a bridge for rvnsqlite:

#[cxx::bridge]
mod ffi {
    #[namespace = "reven::sqlite::ffi"]
    enum OpenDatabaseStatus {
        Ok,
        DatabaseError,
        ReadMetadataError,
    }

    #[namespace = "reven::sqlite::ffi"]
    struct OpenDatabase {
        db: UniquePtr<ResourceDatabase>,
        status: OpenDatabaseStatus,
        error_message: String,
    }

    #[namespace = "reven::sqlite::ffi"]
    struct CreateDatabase {
        db: UniquePtr<ResourceDatabase>,
        status: CreateDatabaseStatus,
        error_message: String,
    }

    #[namespace = "reven::sqlite::ffi"]
    enum CreateDatabaseStatus {
        Ok,
        DatabaseError,
        WriteMetadataError,
    }

    #[namespace = "reven::sqlite::ffi"]
    unsafe extern "C++" {
        include!("rvnsqlite-rs/cxx/sqlite-ffi.h");

        #[namespace = "reven::sqlite"]
        type ResourceDatabase;

        fn open(filename: &str, read_only: bool) -> OpenDatabase;

        #[namespace = "reven::metadata"]
        type Metadata = rvnmetadata_rs::CxxMetadata; // re-exported from the CXX bridge in rvnmetadata-rs

        #[namespace = ""]
        type sqlite3;

        fn create(filename: &str, metadata: &Metadata) -> CreateDatabase;

        fn from_memory(metadata: &Metadata) -> UniquePtr<ResourceDatabase>;

        fn metadata(db: &ResourceDatabase) -> UniquePtr<Metadata>;

        fn get(db: Pin<&mut ResourceDatabase>) -> Pin<&mut sqlite3>;
    }
}

The bridge describes common types, declared in Rust and made available to C++, and has an extern C++ section that describes opaque types (Rust will need to use them behind a UniquePtr) and functions to be implemented in C++. Note how the unsafe is being confined to the extern "C++" section. This is because while calling C++ is unsafe (since C++ has no language-level concept of safe/unsafe), at least the signatures are verified by cxx. The choice to not litter each call to C++ functions through the bridge reduces the unsafe noise created by bindgen. You can still mark individual functions as unsafe if they have preconditions that must be met for soundness.

Once written, the bridge is parsed by a proc macro (the #[cxx::bridge] bit) that checks that there are compatible C++ definitions in the declared header (specified by the include!("rvnsqlite-rs/cxx/sqlite-ffi.h"); bit).

The strength of cxx is that many “higher-level” types can be used in the bridge: C++’s std::string (although it cannot be passed by value), std::unique_ptr<T>, std::vector<T>, … Even Rust types such as String, Box or &str are made available to C++ through the bridge.

This makes writing the bridge a very high-level affair, without the CString juggling incurred by the alternatives.

On the other hand, this sort of forces one to write some C++ glue, to accommodate for cxx’s set of types, that is both restricted (e.g. no std::string by value) and expanded (access to some Rust types).

Here is what this glue code is looking like for rvnsqlite:

// sqlite-ffi.h
#pragma once

#include <cstdint>
#include <memory>
#include <rust/cxx.h>
#include <rvnmetadata/metadata-common.h>
#include <rvnmetadata/metadata-sql.h>
#include <rvnsqlite/resource_database.h>

namespace reven {
namespace sqlite {
namespace ffi {
struct OpenDatabase;
struct CreateDatabase;

OpenDatabase open(rust::Str filename, bool read_only);

CreateDatabase create(rust::Str filename, const metadata::Metadata &metadata);

std::unique_ptr<ResourceDatabase>
from_memory(const metadata::Metadata &metadata);

std::unique_ptr<metadata::Metadata> metadata(const ResourceDatabase &db);

sqlite3 &get(ResourceDatabase &db);

} // namespace ffi
} // namespace sqlite
} // namespace reven

// sqlite-ffi.cpp
#include <memory>
#include <rvnsqlite-rs/cxx/sqlite-ffi.h>
#include <rvnsqlite-rs/src/ffi.rs.h>

namespace reven {
namespace sqlite {
namespace ffi {
OpenDatabase open(rust::Str filename, bool read_only) {
  auto cxx_filename = std::string(filename);
  try {
    auto db = std::make_unique<ResourceDatabase>(
        ResourceDatabase::open(cxx_filename.c_str(), read_only));
    return OpenDatabase{std::move(db), OpenDatabaseStatus::Ok, {}};
  } catch (DatabaseError &e) {
    return OpenDatabase{nullptr, OpenDatabaseStatus::DatabaseError, e.what()};
  } catch (ReadMetadataError &e) {
    return OpenDatabase{nullptr, OpenDatabaseStatus::ReadMetadataError,
                        e.what()};
  }
}

CreateDatabase create(rust::Str filename,
                      const reven::metadata::Metadata &metadata) {
  auto cxx_filename = std::string(filename);
  try {
    auto db = std::make_unique<ResourceDatabase>(ResourceDatabase::create(
        cxx_filename.c_str(), metadata::to_sqlite_raw_metadata(metadata)));
    return CreateDatabase{std::move(db), CreateDatabaseStatus::Ok, {}};
  } catch (DatabaseError &e) {
    return CreateDatabase{nullptr, CreateDatabaseStatus::DatabaseError,
                          e.what()};
  } catch (WriteMetadataError &e) {
    return CreateDatabase{nullptr, CreateDatabaseStatus::WriteMetadataError,
                          e.what()};
  }
}

std::unique_ptr<ResourceDatabase>
from_memory(const metadata::Metadata &metadata) {
  return std::make_unique<ResourceDatabase>(
      ResourceDatabase::from_memory(to_sqlite_raw_metadata(metadata)));
}

std::unique_ptr<metadata::Metadata> metadata(const ResourceDatabase &db) {
  return std::make_unique<metadata::Metadata>(
      metadata::from_raw_metadata(db.metadata()));
}

sqlite3 &get(ResourceDatabase &db) {
    return *db.get();
}


} // namespace ffi
} // namespace sqlite
} // namespace reven

One can notice how the Rust types are being used, and how this implements the functions described in the extern "C++" part of the bridge.

In its current state, cxx is still missing a few bits that would be tremendously useful:

  • The ability to bind Rust enums with payloads, that could be exposed with a std::variant-like API. This ability applied in particular to Option<T>!
  • Exceptions are mapped to a single error type passed in a Result (or causing a panic, if the function is not declared as returning a result), and the Error variant of a Result is turned into an exception. Again, a std::variant-like API (in both directions) could benefit to have more type-safe exceptions (meanwhile I’m declaring types such as CreateDatabase and OpenDatabase to retrieve errors in a type-safe way).
  • The concept of iterator, that exists in both C++ and Rust albeit in two very different shapes, is not mapped in any way, although it could be very useful (to be precise, supported types expose iterators in their APIs, but we cannot model it in a cxx bridge).
  • No support for associated functions.

More generally, there is some kind of dichotomy between the “built-in supported” types and the concepts they enable and the user-defined types that feel less “first-class”.

Still, in practice the approach is very usable. Due to the amount of glue involved, it is, like for cpp!, best to minimize the API surface that will be shared. In contrast with cpp!, cxx bridges C++’s and Rust’s high-level concept, which results in much safer interoperability.

(the build.rs part is very similar to that using cpp, only with cxx instead of cpp)

What about autocxx?

autocxx is a crate built on top of cxx that aims at automating the bridge generation. As such autocxx would retain the high-level-ness of cxx, while binding being automatic like bindgen. The effort is mainly led by Adrian Taylor who works on Chrome security at Google.

Unfortunately, I have not been able to bridge either rvnmetadata or rvnsqlite using autocxx despite several attempts. In the latest attempt, I was able to generate some bridge, but the documentation was missing (apparently due to type alias with private types? Running cargo doc with --document-private-items would fix that issue) and the Metadata type could not be generated (probably due to some unsupported type in its API?).

autocxx is evolving rapidly, so I plan on revisiting it again in some time.

Rust -> C++: Integrating Rust libraries

What about the other direction? The options here are a bit more limited.

cbindgen allows to generate C or C++ headers from the extern "C" fn of a Rust crate, but this approach is very limited:

  • Although the generated headers can be in C++, there is no support for C++ concepts such as destructors or standard types.
  • Obviously you need to write the extern "C" fn, which again limits you to repr(C) types.

Fortunately, cxx (and autocxx I guess?) supports an extern "Rust" section in the bridge, which enables to expose opaque Rust types and functions to C++. The proc-macro checks that the items are actually defined in the crate that contains the bridge.

Here is a small example of a cxx for binding the demangling support from the symbolic crate:

/// Types shared with C++.
#[cxx::bridge]
pub mod ffi {
    #[derive(Debug, Clone)]
    #[namespace = "rvnsymbolic"]
    /// Shared struct to replace Option<DemangledSymbol> since this is not currently compatible with CXX.
    ///
    /// When `has_demangled_symbol` is `false`, users of this struct should not read the `demangled_symbol` field.
    pub struct MaybeDemangledSymbol {
        /// The inner demangled symbol, if any.
        pub demangled_symbol: DemangledSymbol,
        /// Whether or not there is an inner demangled symbol.
        pub has_demangled_symbol: bool,
    }

    #[derive(Debug, Clone, Default)]
    #[namespace = "rvnsymbolic"]
    /// Shared struct that represents a demangled symbol
    pub struct DemangledSymbol {
        /// Only the name of symbol
        pub name: String,
        /// The full symbol with name, parameters and return type
        pub full: String,
    }

    #[namespace = "rvnsymbolic"]
    extern "Rust" {
        fn demangle(symbol: &CxxString) -> MaybeDemangledSymbol;
    }
}

use cxx::CxxString;
use symbolic::{
    common::Name,
    demangle::{Demangle, DemangleOptions},
};

use crate::ffi;

impl ffi::MaybeDemangledSymbol {
    fn none() -> Self {
        Self {
            has_demangled_symbol: false,
            demangled_symbol: Default::default(),
        }
    }
}

/// Attempts to demangle a symbol name, returning None if this fails.
pub fn demangle(symbol: &CxxString) -> ffi::MaybeDemangledSymbol {
    let mangled_name = if let Ok(symbol) = symbol.to_str() {
        Name::from(symbol)
    } else {
        // Early return of "none"
        return ffi::MaybeDemangledSymbol::none();
    };
    let name = mangled_name.demangle(DemangleOptions::name_only());

    let name = if let Some(name) = name {
        name
    } else {
        return ffi::MaybeDemangledSymbol::none();
    };

    let full = mangled_name.demangle(DemangleOptions::complete());
    if let Some(full) = full {
        ffi::MaybeDemangledSymbol {
            has_demangled_symbol: true,
            demangled_symbol: ffi::DemangledSymbol { name, full },
        }
    } else {
        ffi::MaybeDemangledSymbol::none()
    }
}

The lack of Option<T> is keenly felt in the Rust -> C++ direction, and so is the lack of iterators:

/// Types shared with C++.
#[cxx::bridge]
pub mod ffi {
    // clippy rightfully doesn't want the explicit lifetimes in the symbol function,
    // but CXX requires them.
    #![allow(clippy::needless_lifetimes)]

    #[derive(Debug, Clone)]
    #[namespace = "rvnsymbolic"]
    /// Shared struct to replace Option<Symbol> since this is not currently compatible with CXX.
    ///
    /// When `has_symbol` is `false`, users of this struct should not read the `symbol` field.
    pub struct MaybeSymbol {
        /// The inner symbol, if any.
        pub symbol: Symbol,
        /// Whether or not there is an inner symbol.
        pub has_symbol: bool,
    }

    #[derive(Debug, Clone, Default)]
    #[namespace = "rvnsymbolic"]
    /// Shared struct that represents the data of a symbol.
    pub struct Symbol {
        /// The name of the symbol.
        pub name: String,
        /// The virtual address of this symbol relative to the start of the binary.
        pub rva: u64,
        /// If true, the symbol denotes a function, otherwise it denotes some piece of data.
        pub is_function: bool,
        /// Whether or not this symbol is public.
        pub is_public: bool,
        /// The name of the section that this symbol is in.
        pub section_name: String,
    }

    #[namespace = "rvnsymbolic"]
    extern "Rust" {
        type Pdb;

        fn open_pdb(path: &CxxString) -> Result<Box<Pdb>>;
        fn guid(&mut self) -> Result<String>;
        fn age(&mut self) -> Result<u32>;

        /// SAFETY: C++ callers must ensure: Lifetime(Pdb) > Lifetime(Iterator)
        unsafe fn symbols<'a>(&'a mut self) -> Result<Box<PdbSymbolIterator<'a>>>;
    }

    #[namespace = "rvnsymbolic"]
    extern "Rust" {
        type PdbSymbolIterator<'a>;

        pub fn next(&mut self) -> Result<MaybeSymbol>;
    }
}

This can then be used from C++:

for (auto maybe_symbol = symbols.next(); maybe_symbol.has_symbol; maybe_symbol = symbols.next()) {
    const rvnsymbolic::Symbol& symbol = *maybe_symbol;
    // access e.g. symbol.name, symbol.rva, ...
}
// we would prefer a for (const auto& symbol: symbols) {} ...

Still this is big step up from cbindgen and a raw C API.

Actually using these Rust bindings from C++ requires a little CMake dance, and since CMake comes in many shades of pain, it quickly became specific to our use of CMake. Here is a good starting point for your own projects, though.

Conclusion

This concludes this tour of the C++ <-> Rust interop options in today’s ecosystem.

Here’s a summary in table form of the various options:

Crate Glue code Common types Destructors Exceptions Templates Relocatable classes Rust -> C++ direction
bindgen No glue code Not supported Manually called Not supported Not supported Unsound if bound by value No
cpp Extensive glue code unique_ptr & shared_ptr Supported Not supported Not supported Unsound if bound by value No
cxx Somewhat extensive glue code Yes, many Supported Supported Supported Sound by preventing their binding by value😅 Yes
autocxx2 Minimal glue code Yes Supported Supported Supported Experimental moveit integration Planned?

For the time being, the best suited approach for Tetrane is cxx, but it is interesting to watch closely how this space evolves. autocxx has a lot of interesting promises, and in practice it already makes sense today to mix and match cxx with bindgen3.

Having a good interoperability story with C++ is very important for Rust to be used in C++ codebases, let’s hope that the ecosystem continues to improve!

Footnotes

  1. David Tolnay is responsible of around 1/13 of the total downloads from crates.io

  2. The author of this article could not get his projects to work with autocxx at the time of writing. 

  3. We do this for example to generate the ResourceType enum of our rvnmetadata library with bindgen automatically, then use it in the CXX bridge. 

Next post: Keep timeless analysis records to the point with REVEN and GDB
Previous post: Automatic post-fuzzing triage and automation using REVEN