I was considering working on a program that would require scanning for publicly available Wi-Fi that is compatible across major operating systems. I looked up available crates that could handle such a task, but to no avail - no actively maintained crate did that.
Building from scratch seemed like the go-to option at that point; however, I only had access to macOS, hence testing for other OSes was a pain in the ass, and honestly, it didn’t feel like it was worth it.
So I decided to explore one of the unmaintained crates. Last commit? Over 5 years ago.
I tried it locally on macOS only to find out it wasn’t working and returned an interesting error:
thread 'main' (4539332) panicked at src/bin/main.rs:2:40:
Cannot scan network: CommandNotFound
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Now that’s interesting.
Not much context was provided, but we had a clue - the command we were using is most likely not available or simply not found.
Back to the code. The error could be traced to how it attempts to get available Wi-Fi networks.
It uses Rust’s standard library
std::process::Command, which allows you to
execute Unix-based commands, basically what you’d normally
type in your terminal.
In this case, the crate calls the macOS airport CLI:
Command::new(
"/System/Library/PrivateFrameworks/Apple80211.\
framework/Versions/Current/Resources/airport",
)
.arg("-s")
.output()
.map_err(|_| Error::CommandNotFound)?;
As we can see, our error originated from here.
But why?
A few years ago, Apple deprecated that API, citing security
concerns. The airport binary still exists in
some versions, but its usage has been discouraged and
restricted. So relying on it programmatically is fragile at
best.
Instead, Apple provides access via its Objective-C framework called CoreWLAN.
CoreWLAN allows you to scan for available Wi-Fi
networks, but it comes with caveats. Some fields, like
BSSID, security type, etc., may not always be exposed
depending on permissions and OS version.
So now we have a new problem.
I’m writing in Rust. CoreWLAN API is exposed in Objective-C.
How do we bridge that?
Enters FFI.
So, what is Foreign Function Interface (FFI)?
Foreign Function Interface (FFI) is a mechanism that allows programmers writing in one language to call functions written in another language.
Rust has first-class support for FFI, particularly with C and C++. Which makes sense as Rust was designed to interoperate with existing C/C++ ecosystems, and it uses the same underlying linking model
In our case, we were trying to access Objective-C++ code. Rust does not directly interoperate with Objective-C++. However, since Objective-C++ is essentially C++ with Objective-C extensions, we can expose a C-compatible interface and link against it.
Back to Rust, piecing all these together.
Introducing Our “Foreign Friends” to Rust
Logically, it would make sense to first build our foreign friends before Rust builds the crate, and that’s exactly what happens.
First, we have to introduce our foreign friends to Rust by
introducing our build script [build.rs].
build.rs lives at the root of your project.
When you run cargo build, Cargo automatically
runs it before compiling your Rust code.
For this program, we use the cc crate.
Add it under build-dependencies
[build-dependencies]
cc = "1.2.14"
That checked. Let’s introduce our objective c++ file to cc to compile for us
cc::Build::new()
.file("src/ffi_c/scan.mm")
.cpp(true) // important for Objective-C++
.flag_if_supported("-std=c++17") // optional
.compile("wifiscanner");
This compiles our Objective-C++ file (.mm) and
links it into our Rust project.
From there, we can then import our C++ function to our Rust
code via the extern block, where we declare our
foreign functions here, looking similar to what we have in
our C++ code
unsafe extern "C" {
pub(crate) fn cc_scan() -> WifiScanResult;
pub(crate) fn cc_free_scan(result: WifiScanResult);
}
The full implementation can be found here
Let’s explore another Example
This time more straightforward. We’d be using another
library called bindgen.
bindgen approaches ffi differently; this time,
it automatically generates Rust FFI bindings, so instead of
having an extern block with functions with the exact
function name as the C++ function, bindgen does
it for you.
Just as we did with cc, we'd also import our
bindgen crate via the
build-dependencies section of our
Cargo.toml
[build-dependencies]
bindgen = "0.55.1"
That also means you have to declare where you want the
generated code to exist. Customarily, it’s saved in our
OUT DIR. We get that done by adding this to our
build script
let bindings = bindgen::Builder::default()
.header("libebpf.h")
.clang_arg("-I/opt/homebrew/include")
.allowlist_function("bpf_object__load")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
You might be wondering why not use bindgen in the first example. The reason is that, for the first example, Rust cannot directly call Objective-C++ objects hence need for a C wrapper as a bridge.
In this case, we’re calling the
bpf_object__load “user” function from
libbpf, which is part of the eBPF ecosystem.
Speaking of which, eBPF is a Linux kernel technology that allows safe, sandboxed programs to run inside the kernel written in C++.
With all these checked, we can then call our generated code in our code:
mod ffi {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
let obj = unsafe { ffi::bpf_object__load(obj_struct) };
The full implementation can be found here
Final note
When developing programs, huddles would always come up, some annoying, but they must be crossed. And as we have seen here, we have no choice but to interact with code written in a completely different language.
Thanks to FFI, we’re able to achieve our goals