What is vendoring about?
I'm writing this post because basically I haven't found any guide about this, and for sure with this level of completeness and masochism.
So what are we even talking about? Have you ever used the native-tls crate?
Well it is an abstraction for TLS stuff in Rust and since it's older than rustls, on linux systems it has to rely on OpenSSL.
The issue is that, openssl is a C library and usually when you implement stuff in C you have to link it dynamically, but in Rust we like to statically link everything, also because dynamic linking for binaries is a pain, since the binary may not work on other systems and so releasing bins is a hustle.
But here comes the vendored
feature in help! Because with this feature enabled, openssl gets automagically built while building the crate and statically embedded into your binary and so we're all happy.

But if we can be happy today it's because someone has suffered before us to vendor a crate. And that someone, today, is me.
The complete guide to vendoring in Rust
The project structure
Let's start with the project structure. If we have a foo
crate, that is using ffi to C code, we usually have a workspace with the following structure:
foo
: rust crate exposing the rust apifoo-sys
: rust crate exposing the C api
and Inside our foo-sys
we have a build.rs
with the directives for the linker to find the C library, like this:
println!("cargo:rustc-link-lib=foo");
Now if we want to vendor the libfoo
we have to create a new crate, which will be called foo-src
and will be a library containing the functions to compile the C library and optionally the sources of the C library.
The src crate
First we setup a Cargo.toml
with cc
as a dependency, which is a crate that allows us to invoke the C/C++ compiler from Rust.
[dependencies]cc = "1"
and then we can start writing the lib.rs
which will expose a build
function that will compile the C code and will return back two things:
- the path to the include directory for the library
- the path to directory containing the static library (e.g.
/usr/lib/libfoo.a
->/usr/lib
)
So let's set this up:
/// Artifacts produced by the build process.pub struct Artifacts {pub lib_dir: PathBuf,pub include_dir: PathBuf,}/// lib versionpub fn version() -> &'static str {env!("CARGO_PKG_VERSION")}/// Build configurationpub struct Build {out_dir: Option<PathBuf>,target: Option<String>,host: Option<String>,}impl Build {/// Init a new [`Build`] configuration.pub fn new() -> Build {Build {out_dir: env::var_os("OUT_DIR").map(|s| PathBuf::from(s).join("lib-build")),target: env::var("TARGET").ok(),host: env::var("HOST").ok(),}}pub fn out_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Build {self.out_dir = Some(path.as_ref().to_path_buf());self}pub fn target(&mut self, target: &str) -> &mut Build {self.target = Some(target.to_string());self}pub fn host(&mut self, host: &str) -> &mut Build {self.host = Some(host.to_string());self}// ...}
then we need a function to invoke make
:
fn cmd_make(&self) -> Result<Command, &'static str> {let host = &self.host.as_ref().ok_or("HOST dir not set")?[..];Ok(if host.contains("dragonfly")|| host.contains("freebsd")|| host.contains("openbsd")|| host.contains("solaris")|| host.contains("illumos"){Command::new("gmake")} else {Command::new("make")},)}
and one helper to run commands during the build process:
#[track_caller]fn run_command(&self, mut command: Command, desc: &str) -> Result<(), String> {println!("running {:?}", command);let status = command.status();let verbose_error = match status {Ok(status) if status.success() => return Ok(()),Ok(status) => format!("'{exe}' reported failure with {status}",exe = command.get_program().to_string_lossy()),Err(failed) => match failed.kind() {std::io::ErrorKind::NotFound => format!("Command '{exe}' not found. Is {exe} installed?",exe = command.get_program().to_string_lossy()),_ => format!("Could not run '{exe}', because {failed}",exe = command.get_program().to_string_lossy()),},};println!("cargo:warning={desc}: {verbose_error}");Err(format!("Error {desc}:{verbose_error}Command failed: {command:?}"))}
Now we can finally start thinking about the build function.
Compiling the library
Now we can implement the build
function, which will compile the library.
We'll see two scenarios today, the first is the simplest one, which means that your C code is already producing with a couple of commands a static library and an include directory.
The second one, is the masochistic one, where your library is huge and the make won't produce a static library, and yeah, I'm talking about you, samba.
Happy C compilation
So in the best possible scenario we'll have something like this to get a static library for our C code:
./configuremakemake install DESTDIR=$(pwd)/out
and we'll have the static library in out/usr/local/lib/libfoo.a
and the include directory in out/usr/local/include
.

If you're in a case like this, let's see how to write the build function:
pub fn try_build(&mut self) -> Result<Artifacts, String> {let target = &self.target.as_ref().ok_or("TARGET dir not set")?[..];let host = &self.host.as_ref().ok_or("HOST dir not set")?[..];let os = Self::os(target)?;let out_dir = self.out_dir.as_ref().ok_or("OUT_DIR not set")?;let build_dir = out_dir.join("build");if build_dir.exists() {fs::remove_dir_all(&build_dir).map_err(|e| format!("build_dir: {e}"))?;}let inner_dir = build_dir.join("src");fs::create_dir_all(&inner_dir).map_err(|e| format!("{}: {e}", inner_dir.display()))?;// here get the directory for your sources; I STRONGLY SUGGEST CLONING A GIT REPO here// read further chapters for thislet src_dir = todo!();// init cclet mut cc = cc::Build::new();cc.target(target).host(host).warnings(false).opt_level(2);let compiler = cc.get_compiler();let mut cc_env = compiler.cc_env();if cc_env.is_empty() {cc_env = compiler.path().to_path_buf().into_os_string();}// get arlet ar = cc.get_archiver();// configurelet mut configure = Command::new("sh");configure.arg("./configure");// here you can add your configure flagsconfigure.arg("--disable-python");configure.arg("--without-systemd");configure.arg("--without-ldb-lmdb");configure.arg("--without-ad-dc");configure.arg("--bundled-libraries=ALL");configure.arg("--without-libarchive");configure.env("CC", cc_env);configure.env("AR", ar.get_program());let ranlib = cc.get_ranlib();let mut args = vec![ranlib.get_program()];args.extend(ranlib.get_args());configure.env("RANLIB", args.join(OsStr::new(" ")));configure.current_dir(&src_dir);// run configureself.run_command(configure, "configuring foo build")?;// makelet make = self.cmd_make()?;make.current_dir(&src_dir);self.run_command(make, "building foo")?;// create out dirlet out_dir = src_dir.join("out");fs::create_dir_all(&out_dir).map_err(|e| format!("{}: {e}", out_dir.display()))?;// installlet install = Command::new("make");install.arg("install");install.arg(format!("DESTDIR={}", out_dir.display()));install.current_dir(&src_dir);self.run_command(install, "installing foo")?;// build static library -> /usr/local/liblet lib_dir = out_dir.join("usr").join("local").join("lib");// include_dir -> /usr/local/includelet include_dir = out_dir.join("usr").join("local").join("include");Ok(Artifacts {lib_dir,include_dir,})}
And with this simple function, trust me or not, you'll have a static library and an include directory for your C code.
But, if you're not as lucky as me with samba, you'll have to read the next chapter.
Masochistic C compilation
So let's say you have mastodontic C project which for some reason won't build any static library, but only shared ones.
Well... in that case you'll have to build the static library by yourself, but don't worry, it's a long process, but not that hard actually.

Generally, the configure part is the same as before, but the make part will be different.
If your make command produces the shared object, you'll have to execute it anyway.
At that point you'll have to get a list of all the object files required to build the shared object. I won't tell you exactly how to do it, because there are many ways to do it, some people say to use objdump
or ldd
, but many times it won't work.
If nothing works, a good alternative is to run the make command with -V=1
argument, redirect the output to a file and then parse the output with a script to get the list of object files, like this one:
filename = argv[1]with open(filename, "r") as f:lines = f.readlines()objects = []for line in lines:# get if building libif "-Wl,--as-needed" in line:# split by ','tokens = line.split(",")for token in tokens:# strip "'"token = token.strip().strip("'").strip('"')if token.endswith(".o"):# keep only .cend = token.find(".c")token = token[: end + 2]if token not in objects:objects.append(token)for obj in objects:print(f'"{obj}",')
Sorry Rustaceans about the Python snippet, but I use it for these kind of tasks.
In case you've managed to get a list of objects that your make command is using, then we have everything we need to build the static library.
// list of objects to buildconst OBJECTS: &[&str] = &[/* ... */];pub fn try_build(&mut self) -> Result<Artifacts, String> {let target = &self.target.as_ref().ok_or("TARGET dir not set")?[..];let host = &self.host.as_ref().ok_or("HOST dir not set")?[..];let os = Self::os(target)?;let out_dir = self.out_dir.as_ref().ok_or("OUT_DIR not set")?;let build_dir = out_dir.join("build");if build_dir.exists() {fs::remove_dir_all(&build_dir).map_err(|e| format!("build_dir: {e}"))?;}let inner_dir = build_dir.join("src");fs::create_dir_all(&inner_dir).map_err(|e| format!("{}: {e}", inner_dir.display()))?;// get src at `inner_dir`let src_dir = todo!();// init cclet mut cc = cc::Build::new();cc.target(target).host(host).warnings(false).opt_level(2);let compiler = cc.get_compiler();let mut cc_env = compiler.cc_env();if cc_env.is_empty() {cc_env = compiler.path().to_path_buf().into_os_string();}// get arlet ar = cc.get_archiver();// configurelet mut configure = Command::new("sh");configure.arg("./configure");configure.arg("--disable-python");configure.arg("--without-systemd");configure.arg("--without-ldb-lmdb");configure.arg("--without-ad-dc");configure.arg("--bundled-libraries=ALL");configure.arg("--without-libarchive");#[cfg(target_os = "macos")]configure.arg("--without-acl-support"); // not supported on macconfigure.env("CC", cc_env);configure.env("AR", ar.get_program());let ranlib = cc.get_ranlib();let mut args = vec![ranlib.get_program()];args.extend(ranlib.get_args());configure.env("RANLIB", args.join(OsStr::new(" ")));configure.current_dir(&src_dir);// run configureself.run_command(configure, "configuring foo build")?;// makelet make = self.cmd_make()?;make.current_dir(&src_dir);self.run_command(make, "building foo")?;// we use AR to build the static librarylet mut build_static = cc.get_archiver();build_static.arg("rcs");build_static.arg("libfoo.a");build_static.current_dir(&src_dir);// push objectfor object in OBJECTS {let path = inner_dir.join(object);build_static.arg(path.display().to_string());}// run arself.run_command(build_static, "building static library")?;// include_dir -> ??? include/let include_dir = src_dir.join("include");Ok(Artifacts {lib_dir: src_dir,include_dir,})}
And with that the hardest part is done, now we just have to add the vendored
feature to our foo
and foo-sys
crates and to run the build script in the foo-sys
crate.
Running the build script
At this point we add a new vendored
feature to our foo-sys
crate:
[build-dependencies]cc = { version = "1", optional = true }foo-src = { version = "4.22.0", path = "../foo-src", optional = true }[features]vendored = ["dep:cc", "dep:foo-src"]
and in the build.rs
we switch between the vendored build and the linking to the dynlib:
fn main() {#[cfg(feature = "vendored")]{build_vendored();}#[cfg(not(feature = "vendored"))]{build_normal();}}fn build_normal() {println!("cargo:rustc-link-lib=foo");}#[cfg(feature = "vendored")]fn build_vendored() {let mut build = foo_src::Build::new();println!("building vendored foo library... this may take several minutes");let artifacts = build.build();println!("cargo:vendored=1");println!("cargo:root={}",artifacts.lib_dir.parent().unwrap().display());if !artifacts.lib_dir.exists() {panic!("foo library does not exist: {}",artifacts.lib_dir.display());}if !artifacts.include_dir.exists() {panic!("foo include directory does not exist: {}",artifacts.include_dir.display());}println!("cargo:rustc-link-search=native={}",artifacts.lib_dir.display());println!("cargo:include={}", artifacts.include_dir.display());println!("cargo:rustc-link-lib=static=foo");}
Finally, let's also add the vendored
feature to the foo
crate:
[features]vendored = ["foo-sys/vendored"]
And that's it! Now you can build your project with the vendored
feature and the C library will be statically linked to your binary.
Good luck with the compilation!

Extra - About including sources
In general there are two options for including the C code into the -src
crate:
- Using git submodules
- Cloning the git repository during the build process
For instance openssl-src
opts for the first, but you cannot always opt for that. For instance, with samba I couldn't do that, because the samba repository is huge and literally crates.io rejected the crate because of the size.
So in case, here's the code to clone the repo in the lib.rs
if you need it (the example uses samba):
[dependencies]git2 = "0.20"
/// Clone samba repository to the given path and checkout the tagfn clone_samba(p: &Path) -> Result<(), String> {let repo_url = "https://git.samba.org/samba.git";let repo = git2::Repository::clone(repo_url, p).map_err(|e| format!("cloning samba: {e}"))?;// checkout tag "samba-4.22.0"let tag = format!("samba-{}", version());let obj = repo.revparse_single(&tag).map_err(|e| format!("revparse_single: {e}"))?;let commit = obj.peel_to_commit().map_err(|e| format!("peel_to_commit: {e}"))?;repo.checkout_tree(&obj, None).map_err(|e| format!("checkout_tree: {e}"))?;repo.set_head_detached(commit.id()).map_err(|e| format!("set_head_detached: {e}"))?;Ok(())}
So in this case, the src_dir will be the path passed to clone_samba
.
Extra - Linking static libraries dependencies
In some cases, such as samba you also depend on other libraries, such as libtalloc
, libtevent
, libtdb
and so on.
In these cases, you'll have to link these libraries to your static library.
So in your build.rs
for foo-sys
you'll have to add the directives to link those libraries as well:
fn build_vendored() {// ...add_library("icuuc", "icu4c");add_library("gnutls", "gnutls");add_library("bsd", "libbsd");add_library("resolv", "libresolv");// ...}fn add_library(lib: &str, brew_name: &str) {// search lib with pkg-config and try staticmatch pkg_config::Config::new().statik(true).cargo_metadata(true).probe(lib){Ok(_) => {if cfg!(target_os = "macos") {if cfg!(target_arch = "aarch64") {println!("cargo:rustc-link-search=/opt/homebrew/opt/{brew_name}/lib");} else if cfg!(target_arch = "x86_64") {println!("cargo:rustc-link-search=/usr/local/Homebrew/opt/{brew_name}/lib");}println!("cargo:rustc-link-lib={lib}");}}Err(_) => {println!("{lib} was not found with pkg_config; trying with LD_LIBRARY_PATH; but you may need to install it manually");// cross-finger and try dylibprintln!("cargo:rustc-link-lib={lib}");}};}
In this case I'm also using pkg-config to find the library and I also try to link them statically, but in case it doesn't work, I fallback to dynamic linking.
Extra - Loading embedded shared objects?
When I was working on vendoring libsmbclient I was about to give up because I couldn't get the static library to build, so I also thought that there could be a way to vendoring the shared object.
For those who don't know you, if you want to run a binary linked with a shared object, you need the same shared object in the system, so binaries become much less portable.
An idea that came to my mind was to put the shared object in a known path to the project and then in the source code to embed the shared object with include_bytes!()
and then like create a function to initialize the library which would have loaded the shared object from the bytes using libloading.
So it would have been something like that
const LIBSMBCLIENT: &[u8] = include_bytes!("libsmbclient.so");fn init_libsmbclient() {let lib = tempfile::NamedTempFile::new().unwrap();lib.write_all(LIBSMBCLIENT).unwrap();let lib = libloading::Library::new(lib.path()).unwrap();}
Or something like that. I've got no idea if it works, I will maybe try that in the future.
EDIT: I've tried it, and it works! If you want to read more about it, you can read this post.

Conclusions
So that's it, I hope this guide will help you to vendor your C/C++ dependencies in Rust and I hope it becomes a reference for the future, maybe linked/copied somewhere in some Rust book. You can do it, as long as I'm credited.