Generating .lib with Rust build scripts
Something I’ve been working on in my spare time is porting Azure SignTool to Rust. I’ve yet to make up mind if Rust is the one-true way forward with that, but that’s a thought for another day.
I wanted to check out the feasibility of it. I’m happy to say that I think all of the concepts necessary are there, they just need to be glued together.
One roadblock with Azure SignTool is that it needs to use an API,
SignerSignEx3
, which isn’t included in the Windows SDK. In fact, just about
nothing in mssign32
is in the Windows SDK. Not being in the Windows SDK means
no headers, and no .lib to link against.
For .NET developers, no .lib for linking hasn’t really mattered when consuming Win32 APIs. It simply needs the ordinal or name of the export and the CLR takes care of the rest with platform invoke. For languages like C that use a linker, you need a .lib to link against. Rust is no different.
For most cases, the winapi
crate has all of the Win32 functions you
need. It’s only in the case of APIs that are not in the Windows SDK (or like
SignerSignEx3
, entirely undocumented) that an API will not be in the crate.
We need to call SignerSignEx3
without something to link against. We have a few
different options.
- Use
LoadLibrary(Ex)
andGetProcAddress
. - Make our own .lib.
The latter seemed appealing because then the Rust code can continue to look clean.
#[link(name = "mssign32")]
extern {
fn SignerSignEx3(...)
}
Making a .lib that contains exports only is not too difficult. We can define our own .def file like so:
LIBRARY mssign32
EXPORTS
SignerSignEx3
and use lib.exe
to convert it to a linkable lib file:
lib.exe /MACHINE:X64 /DEF:mssign32.def /OUT:mssign32.lib
If we put this file somewhere that the Rust linker can find it, our code will compile successfully and we’ll have successfully linked.
I wasn’t thrilled about the idea of checking in an opaque binary in to source for building, so I sought an option to make it during the rust build process.
Fortunately, cargo makes that easy with build scripts. A build script is a rust
file itself named build.rs
in the same directory as your Cargo.toml
file.
It’s usage is simple:
fn main() {
// Build script
}
Crucially, if you write to stdout using println!
, the build process will
recognize certain output as commands to modify the build process. For example:
println!("cargo:rustc-link-search={}", "C:\\foo\\bar");
Will add a path for the linker to search. We can begin to devise a plan to make
this part of a build. We can in our build call out to lib.exe
to generate a
.lib to link against, shove it somewhere, and add the directory to to linker’s
search path.
The next trick in our build script will be to find where lib.exe
is.
Fortunately, the Rust toolchain already solves this since it relies on link.exe
from Visual Studio anyway, so it knows how to find SDK tooling (which move all
over the place between Visual Studio versions). The cc
crate makes this easy
for us.
let target = env::var("TARGET").unwrap();
let lib_tool = cc::windows_registry::find_tool(&target, "lib.exe")
.expect("Could not find \"lib.exe\". Please ensure a supported version of Visual Studio is installed.");
The TARGET
environment variable is set by cargo and contains the architecture
the build is for, since Rust can cross-compile. Conveniently, we can use this to
support cross-compiled builds of azure_sign_tool_rs
so that we can make 32-bit
builds on x64 Windows and x64 builds on 32-bit Windows. This allows us to modify
the /MACHINE
argument for lib.exe.
I wrapped that up in to a helper in case I need to add additional libraries.
enum Platform {
X64,
X86,
ARM64,
ARM
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match *self {
Platform::X64 => write!(f, "X64"),
Platform::X86 => write!(f, "X86"),
Platform::ARM => write!(f, "ARM"),
Platform::ARM64 => write!(f, "ARM64"),
}
}
}
struct LibBuilder {
pub platform : Platform,
pub lib_tool : cc::Tool,
pub out_dir : String
}
impl LibBuilder {
fn new() -> LibBuilder {
let target = env::var("TARGET").unwrap();
let out_dir = env::var("OUT_DIR").unwrap();
let platform =
if target.contains("x86_64") { Platform::X64 }
else if target.contains("ARM64") { Platform::ARM64 }
else if target.contains("ARM") { Platform::ARM }
else { Platform::X86 };
let lib_tool = cc::windows_registry::find_tool(&target, "lib.exe")
.expect("Could not find \"lib.exe\". Please ensure a supported version of Visual Studio is installed.");
LibBuilder {
platform : platform,
lib_tool : lib_tool,
out_dir : out_dir
}
}
fn build_lib(&self, name : &str) -> () {
let mut lib_cmd = self.lib_tool.to_command();
lib_cmd
.arg(format!("/MACHINE:{}", self.platform))
.arg(format!("/DEF:build\\{}.def", name))
.arg(format!("/OUT:{}\\{}.lib", self.out_dir, name));
lib_cmd.output().expect("Failed to run lib.exe.");
}
}
Then our build script’s main can contain this:
fn main() {
let builder = LibBuilder::new();
builder.build_lib("mssign32");
println!("cargo:rustc-link-search={}", builder.out_dir);
}
After this, I was able to link against mssign32
.
Note that, since this entire project is Windows’s specific and has zero chance
of running anywhere, I did not bother to decorate anything with
#[cfg(target_os = "windows")]
. If you are attempting to make a cross-platform
project, you’ll want to account for all of this in the Windows-specific parts.
With this, I now only need to check in a .def
text file and Cargo will take
care of the rest.