There are plenty of reasons to be excited about Rust. Rust provides cross-platform compatibility and can compile to nearly any platform, including Windows, iOS, Android, and many more. One of Rust’s core features is its focus on memory safety. It accomplishes this through its ownership model, which helps prevent common bugs such as null pointer dereferencing, dangling pointers, and data races.
All of this makes Rust an excellent alternative to C++/C for implementing shared logic and algorithms, spanning many different platforms.
In this post we shall see how we can integrate Rust into program written in C# - and how a native library built with Rust can be called into from a .NET application.
Getting started π
Let’s start with some sample Rust code that will emulate our cross-platform domain. This can be anything really, but for demo purposes we can use the excellent QR code generation library qrcodegen.
First we should create a new Rust library:
mkdir rust-lib
cd rust-lib
cargo init --lib
Next, we can add our dependencies to the Cargo.toml file:
[dependencies]
qrcodegen = "1.8.0"
thiserror = "1.0"
uniffi = { version = "0.23.0", features=["build"] }
Obviously we need qrcodegen; the usage of thiserror and uniffi crates will become clear in a moment, so for now let’s ignore them. We also want to use the following crate types:
[lib]
crate-type = ["lib", "cdylib", "staticlib"]
We can use the most basic example from the qrcodegen example program, with some small modifications, as our starting point:
pub fn generate_qr_code_svg(text: &str) -> String {
let error_correction_level: QrCodeEcc = QrCodeEcc::Low;
let qr: QrCode = QrCode::encode_text(text, error_correction_level).unwrap();
to_svg_string(&qr, 4)
}
The code above allows us to pass in any string, and it will be encoded into the QR code using the error correction at a low level, and it will be converted to SVG string output at the end. The SVG conversion uses another helper from the demo program.
Cross the language boundaries π
With this starting point set, let us now imagine that we would like to call this Rust implementation - being cross platform, after all - from our C# application. How would one go about doing that? Unfortunately, the typical way of doing it would be to use Foreign Function Interface, FFI for short - a bridge between two programming languages written in a low-level “common denominator” language, most often C.
This in turn means that the whole process can be very complex, painful to integrate, littered with problems with memory management and possible lurking bugs - something that, all things considered, one would want to avoid.
This is where UniFFI comes in. It is an open source project from Mozilla, which acts as an automated bridge generator between Rust and several popular languages. Once you have your Rust compiled for the relevant native target platform, UniFFI can generate bindings for your favorite language (providing it supports it), as well as the necessary headers, if needed, allowing you to painlessly call into the compiled Rust library code from those high-level languages.
UniFFI supports bindings generation for Swift, Python, Ruby and Kotlin out of the box. Additionally, third party plugins provide support for Go, Kotlin Multiplatform and C#, the latter of which is of most interest to us today. It is an open source project from Nord Security.
Let’s return to our function defined earlier, generate_qr_code_svg(text: &str) - how would we surface this over FFI using UniFFI? UniFFI uses an interface definition language called UDL (UniFFI Definition Language) for that purpose. This is not yet-another-programming-language but a very simple syntax for expressing the contract between Rust and the calling languages, for which the bindings will be generated. It is hosted in its own dedicated separate file, which is customarily placed right next to your lib.rs.
For our library the UDL file will be called it rust-lib.udl and look as follows:
namespace rust_lib {
string generate_qr_code_svg([ByRef] string text);
};
As you can see it’s super simple, just like our function is, with the string coming in and a string coming out. The only thing worth noting is that there is a [ByRef] attribute that accounts for Rust’s &str string slice.
With that, we are ready to generate our bindings. This can be done manually - by invoking the command line tool for bindings generator, but it’s more elegant to tie this into the Rust build process - this way we are sure our bindings are regenerated whenever our code changes. This way if the definitions of our Rust functions and their matching UDL representations deviate, the cargo build process will fail. To achieve that we need to add uniffi_build and uniffi_bindgen as build dependencies to our cargo file.
[build-dependencies]
uniffi_build = "0.23.0"
uniffi_bindgen = "0.23.0"
Additionally, we should now add a build.rs file next to our library’s cargo file and make sure to invoke binding generation on build:
use std::process::Command;
use uniffi_bindgen::{generate_bindings};
fn main() {
let udl_file = "./src/rust-lib.udl";
let out_dir = "./bindings/";
uniffi_build::generate_scaffolding(udl_file).unwrap();
generate_bindings(udl_file.into(),
None,
vec!["swift", "python", "kotlin"],
Some(out_dir.into()),
None,
true).unwrap();
}
In this case we tell the bindings generator to grab the rust-lib.udl and to output all the bindings into the bindings folder. In the example above, Swift, Python and Kotlin bindings are chosen. What about C# then, the actual topic of our article?
As mentioned, C# support is provided by third party plugin uniffi-bindgen-cs. Unfortunately it does not expose the necessary APIs for us to call it the same way, but if it’s installed on the PATH we can simply invoke it - also during the build process - from the command line as separate process. This means we need to add the following line to our build file’s main():
Command::new("uniffi-bindgen-cs").arg("--out-dir").arg(out_dir).arg(udl_file).output().expect("Failed when generating C# bindings");
In order for this to work, we have to install uniffi-bindgen-cs as a command line tool first, which can be done by calling:
cargo install uniffi-bindgen-cs --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.2.0
Note that we are not installing the latest version as the generator must be compatible with the UniFFI version we use in our project, and the C# plugin 0.2.0 matches UniFFI 0.23.0 which we use in our cargo file.
If we now run cargo build, the file rust_lib.cs should be generated (along with the Kotlin, Swift and Python ones, though we shall ignore them in this post) inside the bindings folder. The contents of this file will contain a lot of boilerplate code for marshaling data and performing FFI conversion, but one important thing should stand out:
public static class RustLibMethods {
public static String GenerateQrCodeSvg(String @text) {
return FfiConverterString.INSTANCE.Lift(
_UniFFIHelpers.RustCall( (ref RustCallStatus _status) =>
_UniFFILib.rust_lib_e897_generate_qr_code_svg(FfiConverterString.INSTANCE.Lower(@text), ref _status)
));
}
}
Notice that because we used a global function in Rust, which are not supported in C#, the generator was smart enough to create a static class called RustLibMethods (the name derived from the namespace we defined in the UDL file) and a static method with a name that is normalized for C# standards - GenerateQrCodeSvg, with String input and output. This is of course very promising so let’s try to invoke this from a C# program.
Integrating the bindings into C# code base π
To get that started, let’s create a new console application C# project:
mkdir csharp-host
cd csharp-host
dotnet new console
At this point, an empty project matching your currently installed .NET SDK (in my case .NET 7.0) is created. We need to make some small changes to it to be able to call into this newly created native library for Rust.
First, our project needs to support unsafe code - this is part of the generated bindings, so we need to add the necessary instruction to our csproj file:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
Next, we need to actually copy the Rust compiled binary to the application’s bin folder, so that the bindings code can call into in the first place. This requires us to perform a conditional copy based on the platform - given the library’s name rust-lib, we are going to have librust_lib.dylib on MacOS, librust_lib.so on Linux and rust_lib.dll on Windows. Those files - unless we use some custom parameters when invoking cargo build, would be located in the target/debug folder relative to the root of our rust-lib.
<PropertyGroup>
<NativeOutputPath>../rust-lib/target/$(Configuration.ToLowerInvariant())/</NativeOutputPath>
</PropertyGroup>
<ItemGroup>
<None Condition="$([MSBuild]::IsOsPlatform('MacOS'))" Include="$(NativeOutputPath)librust_lib.dylib" CopyToOutputDirectory="PreserveNewest" />
<None Condition="$([MSBuild]::IsOsPlatform('Linux'))" Include="$(NativeOutputPath)librust_lib.so" CopyToOutputDirectory="PreserveNewest" />
<None Condition="$([MSBuild]::IsOsPlatform('Windows'))" Include="$(NativeOutputPath)rust_lib.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
With that in place, we still need to make sure that the generated bindings file, the rust-lib.cs, is also included in our project. One way of achieving this is to copy it over from the bindings folder to which we generated. Though the simpler way is to simply reference it from that location:
<ItemGroup>
<Compile Include="..\rust-lib\bindings\rust_lib.cs" Link="rust_lib.cs" />
</ItemGroup>
With that in place, we can call into Rust from our C# app:
using uniffi.rust_lib;
var svgCode = RustLibMethods.GenerateQrCodeSvg("https://strathweb.com");
Console.WriteLine(svgCode);
And that’s it! It’s really as simple as that. We can now run the program with:
dotnet run
A more advanced example π
The Rust code we used so far has a few limitations. First of all, the input and output is string based, so there are no complex data structures in play here. Additionally, we use unwrap() so if something goes wrong on the Rust side, it will simply panic - in other words, the error propagation back to C# world is not happening.
Let’s build a more complex example now, but starting a bit backwards - from the UDL file. We shall add a new function to it, called encode_text:
namespace rust_lib {
string generate_qr_code_svg([ByRef]string text);
[Throws=QrError]
QrCode encode_text([ByRef]string text, QrCodeEcc ecl);
};
The function accepts the string to be encoded into the QR code like before, but also, additionally, the error correction level. And instead of returning an SVG string it is now a QrCode struct of some sort. There is also an extra attribute annotation indicating that the function can throw a QrError.
Where do these QrCodeEcc, QrError and QrCode types come from? How does UniFFI know about them? Well it doesn’t, so we need to add them next, by extending the UDL file with the following:
enum QrCodeEcc {
"Low",
"Medium",
"Quartile",
"High",
};
interface QrCode {
i32 size();
boolean get_module(i32 x, i32 y);
};
[Error]
interface QrError {
ErrorMessage(string error_text);
};
UniFFI allows us to specify interfaces, which are effectively objects. In Rust they are associated with structs with impl blocks containing methods. Enums are surfaced as, well, enums - and enums with associated data (which we don’t use here) are also supported, even if the target language does not support them out of the box (they would not get mapped to that language’s native enums then but to something else, like classes).
The [Throws=QrError] annotation allows us to account for Result<T, E> of Rust, or, more specifically, its error case.
With that in place, we can add the corresponding Rust code:
use std::sync::Arc;
use qrcodegen::{QrCode, QrCodeEcc};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum QrError {
#[error("Error with message: `{error_text}`")]
ErrorMessage { error_text: String }
}
impl QrError {
pub fn message(msg: impl Into<String>) -> Self {
Self::ErrorMessage { error_text: msg.into() }
}
}
pub fn encode_text(text: &str, ecl: QrCodeEcc) -> Result<Arc<QrCode>, QrError> {
QrCode::encode_text(text, ecl)
.map(|qr| Arc::new(qr))
.map_err(|e| QrError::message(e.to_string()))
}
Let’s highlight the more interesting things.
The encode_text function returns a Result<Arc<QrCode>, QrError>. This matches our UDL which expected QrCode on the happy path, and QrError on the error throwing path. We have to wrap the type in Arc because of the safety of memory management - since we are crossing the FFI boundary, UniFFI allocates every object instance on the heap.
The QrError enum is just an enum, which is annotated with #[error] attribute from the thiserror crate and is convertible from string.
Notice that we do not define the types of QrCode and QrCodeEcc - the reason is, they come from the qrcodegen crate and we simply resurface them over FFI, which is very cool. Because of the need of using Arc on object instances, this is not always possible, but it works in this case.
If we now build our Rust project, the bindings generation should automatically kick in, and the generated C# code should be larger than before.
For example, we should see the enum in C#:
public enum QrCodeEcc: int {
LOW,MEDIUM,QUARTILE,HIGH
}
class FfiConverterTypeQrCodeEcc: FfiConverterRustBuffer<QrCodeEcc> {
public static FfiConverterTypeQrCodeEcc INSTANCE = new FfiConverterTypeQrCodeEcc();
public override QrCodeEcc Read(BigEndianStream stream) {
var value = stream.ReadInt() - 1;
if (Enum.IsDefined(typeof(QrCodeEcc), value)) {
return (QrCodeEcc)value;
} else {
throw new InternalException(String.Format("invalid enum value '{}' in FfiConverterTypeQrCodeEcc.Read()", value));
}
}
public override int AllocationSize(QrCodeEcc value) {
return 4;
}
public override void Write(QrCodeEcc value, BigEndianStream stream) {
stream.WriteInt((int)value + 1);
}
}
The QrCode type, containing the methods to retrieve the necessary information about the generated QR code:
public interface IQrCode {
Int32 Size();
Boolean GetModule(Int32 @x, Int32 @y);
}
public class QrCodeSafeHandle: FFISafeHandle {
public QrCodeSafeHandle(): base() {
}
public QrCodeSafeHandle(IntPtr pointer): base(pointer) {
}
override protected bool ReleaseHandle() {
_UniFFIHelpers.RustCall((ref RustCallStatus status) => {
_UniFFILib.ffi_rust_lib_e897_QrCode_object_free(this.handle, ref status);
});
return true;
}
}
public class QrCode: FFIObject<QrCodeSafeHandle>, IQrCode {
public QrCode(QrCodeSafeHandle pointer): base(pointer) {}
public Int32 Size() {
return FfiConverterInt.INSTANCE.Lift(
_UniFFIHelpers.RustCall( (ref RustCallStatus _status) =>
_UniFFILib.rust_lib_e897_QrCode_size(this.GetHandle(), ref _status)
));
}
public Boolean GetModule(Int32 @x, Int32 @y) {
return FfiConverterBoolean.INSTANCE.Lift(
_UniFFIHelpers.RustCall( (ref RustCallStatus _status) =>
_UniFFILib.rust_lib_e897_QrCode_get_module(this.GetHandle(), FfiConverterInt.INSTANCE.Lower(@x), FfiConverterInt.INSTANCE.Lower(@y), ref _status)
));
}
}
class FfiConverterTypeQrCode: FfiConverter<QrCode, QrCodeSafeHandle> {
public static FfiConverterTypeQrCode INSTANCE = new FfiConverterTypeQrCode();
public override QrCodeSafeHandle Lower(QrCode value) {
return value.GetHandle();
}
public override QrCode Lift(QrCodeSafeHandle value) {
return new QrCode(value);
}
public override QrCode Read(BigEndianStream stream) {
return Lift(new QrCodeSafeHandle(new IntPtr(stream.ReadLong())));
}
public override int AllocationSize(QrCode value) {
return 8;
}
public override void Write(QrCode value, BigEndianStream stream) {
stream.WriteLong(Lower(value).DangerousGetRawFfiValue().ToInt64());
}
}
And finally, the QrException too:
public class QrException: UniFFIException {
// Each variant is a nested class
public class ErrorMessage : QrException {
// Members
public String @errorText;
// Constructor
public ErrorMessage(
String @errorText) {
this.@errorText = @errorText;
}
}
}
All that is left is to simply integrate it into our C# console application. Since all the machinery is in place already, we can simply add some code that takes advantage of the newly exposed features from the native library:
var qrCode = RustLibMethods.EncodeText("https://strathweb.com", QrCodeEcc.MEDIUM);
PrintQr(qrCode);
static void PrintQr(QrCode qr)
{
int border = 4;
for (int y = -border; y < qr.Size() + border; y++)
{
for (int x = -border; x < qr.Size() + border; x++)
{
char c = qr.GetModule(x, y) ? 'β' : ' ';
Console.Write($"{c}{c}");
}
Console.WriteLine();
}
Console.WriteLine();
}
And this prints the correct QrCode visualization in the console.
Summary π
Once the underlying infrastructure is in place, it’s really easy to call into Rust from C# using the UniFFI bindings. I hope you will find some use cases for them and that you will give Rust a shot for your cross-platform logic and algorithms.
The source code for this blog post is available on Github.