Wrapping a C Library with Zig
This post is going to look at wrapping a C library in Zig. The library to wrap will be libsodium, a "modern, easy-to-use software library for encryption, decryption, signatures, password hashing and more."
Setup
I'm going to assume you've already got Zig installed. This was last updated in March of 2024 with a build off the master branch, which mostly looks like 0.12.0. Zig is still in development, and the number of breaking changes between here and 1.0 is more than 0, so if you're reading this in the future you may have to adjust some things.
I'm also assuming a version of libsodium along with development headers is installed. If you're running a sensible Linux distro, you should be able to install a package named something like libsodium-dev. I've got libsodium23 and libsodium-dev 1.0.18-1 installed on my Debian system.
The first thing to do is set up the project. $ zig
init
sets us up with a build.zig and a src directory
with a boilerplate main.zig and root.zig. Let's start by
getting rid of main.zig, renaming root.zig to sodium.zig and
updating build.zig with some libraries to link and main's new
name.
// build.zig const Build = @import("std").Build; pub fn build(b: *Build) void { const target = b.standardTargetOptions(.{}); const mode = b.standardOptimizationOptions(.{}); const lib = b.addStaticLibrary(.{ .name = "sodium", .root_source_file = .{ .path = "src/sodium.zig" }, .target = target, .optimize = mode, }); b.installArtifact(lib); var tests = b.addTest(.{ .root_source_file = .{ .path = "src/sodium.zig" }, .target = target, .optimize = mode, }); tests.linkSystemLibrary("c"); tests.linkSystemLibrary("sodium"); const run_tests = b.addRunArtifact(tests); const test_step = b.step("test", "Run library tests"); test_step.dependOn(&run_tests.step); }
Note that I'm just making a library, so the only thing that gets
linked to libc and libsodium is the tests
target.
Wrap a Function
Now let's open up sodium.zig and put in something really basic that will at least compile and link for our tests.
// sodium.zig const c = @cImport({ @cInclude("sodium.h"); }); /// Initialize libsodium. Call before other libsodium functions. pub fn init() !void { if (c.sodium_init() < 0) { return error.InitError; } } test "initialize" { try init(); }
It doesn't look like there's a lot going on here, but this little file has all the tools we're going to use. The first line instructs Zig to translate "sodium.h" to Zig and import it into a module called "c". That's the magic. Then we take sodium_init, which the documentation says returns 0 on success, 1 if already initialized, and -1 on failure, and make it return a Zig error when it fails. There is a little bit of editorial decision making here, since the 1 return code is being ignored, but I think success is success and I don't care about finer detail.
The test actually uses our init function, and now we have something we can run to see if it works.
$ zig build test All 1 tests passed.
Hooray! On to bigger and more interesting things.
Wrapping
The first thing we want to do is make an error set that we can use for this library. Every error we throw is going to be because libsodium returned some error code, and it's nice to group them together. So let's make an errors.zig that just has a SodiumError type that other modules can import.
// errors.zig pub const SodiumError = error{ InitError, };
There, that'll be useful in init()
. Here is an updated
sodium.zig that uses it.
// sodium.zig const c = @cImport({ @cInclude("sodium.h"); }); pub const SodiumError = @import("errors.zig").SodiumError; /// Initialize libsodium. Call before other libsodium functions. pub fn init() SodiumError!void { if (c.sodium_init() < 0) { return SodiumError.InitError; } } test "initialize" { try init(); }
Of course, Zig can infer return error sets, so for the rest of this
we'll just have functions return !void
if they would
have returned SodiumError!void
.
Tests pass, good to move on. The first module I am going to convert is crypto_box. This bit of the library lets us generate public/private key pairs and encrypt messages using the public key that can only be decrypted by the private key. Sounds handy.
The first thing to do here is create a new file, crypto_box.zig, with something small that we can get working. We'll wrap the crypto_box_keypair function first, since it's a prerequisite for the rest of the library. It is also nice and small, which makes the job easier.
// crypto_box.zig const std = @import("std"); const SodiumError = @import("errors.zig").SodiumError; const c = @cImport({ @cInclude("sodium.h"); }); pub const PUBLICKEYBYTES = c.crypto_box_PUBLICKEYBYTES; pub const SECRETKEYBYTES = c.crypto_box_SECRETKEYBYTES; /// Generate a public/private key pair for use in other functions in /// this module. pub fn keyPair( pub_key: *[PUBLICKEYBYTES]u8, secret_key: *[SECRETKEYBYTES]u8, ) !void { if (c.crypto_box_keypair(pub_key, secret_key) != 0) { return SodiumError.KeyGenError; } } const sodium = @import("sodium.zig"); test "generate key" { var pub_key: [PUBLICKEYBYTES]u8 = undefined; var secret_key: [SECRETKEYBYTES]u8 = undefined; try sodium.init(); try keyPair(&pub_key, &secret_key); }
Note the new KeyGenError, which I added to errors.zig. This is a
nice improvement on the interface, since it eliminates the whole
class of errors where the buffers passed
to crypto_box_keypair
are the wrong size. It also
guarantees error checking, since Zig won't let you just
call sodium.crypto_box.keyPair
without catching
errors.
Of course, this file by itself does nothing. Now we have to update sodium.zig to run the test.
const c = @cImport({ @cInclude("sodium.h"); }); pub const crypto_box = @import("crypto_box.zig"); pub const SodiumError = @import("errors.zig").SodiumError; /// Initialize libsodium. Call before other libsodium functions. pub fn init() !void { if (c.sodium_init() < 0) { return SodiumError.InitError; } } test "initialize" { try init(); } test "sodium" { _ = @import("crypto_box.zig"); }
There are a few things to notice here. First, I exported our new
module as crypto_box. Anyone who uses this wrapper library will
have a line something like const sodium =
@import("sodium");
and we want those users to be able to get
to sodium.crypto_box
. Second, I added a
new "sodium"
test. This is a Zig idiom for running
tests in exported modules. We don't have to make any changes to
build.zig. The output of $ zig build test
now looks
like this:
$ zig build test All 3 tests passed.
The Fun Part
The code examples are going to start getting long, so instead of reproducing entire files here, I'll point you to sourcehut or, if you really prefer, github where I've made this project available.
Instead of dumping whole files, I'll highlight some ways that Zig
lets us translate awkward and bug-prone C interfaces to easy-to-use
Zig interfaces. First on the list
is crypto_box_seal
. This function is for anonymously
sending messages to a recipient whose public key is known.
/// Turn an arbitrary length message into a ciphertext. ciphertext /// argument must be (message length + SEALBYTES) long. pub fn seal( ciphertext: []u8, message: []const u8, recipient_pk: *const [PUBLICKEYBYTES]u8, ) !void { const msgLen = message.len; const ctxtLen = ciphertext.len; if (ctxtLen < msgLen + SEALBYTES) { return SodiumError.SealError; } const cbSeal = c.crypto_box_seal; if (cbSeal(ciphertext.ptr, message.ptr, msgLen, recipient_pk) != 0) { return SodiumError.SealError; } }
The C interface takes 3 pointers and a message length. It's up to the user to read the documentation and ensure that the ciphertext buffer is the right length, and that the message length is correctly calculated. A user of the C library also has to check error codes and make sure they're not writing garbage out because something went wrong.
The Zig interface, on the other hand, completely avoids the buffer overflow issue and drops the mlen argument, since slices know how long they are. It also takes special effort to ignore an error return, as opposed to C where it takes effort to handle it correctly.
Another thing I've barely mentioned is the namespacing. I took a
function called crypto_box_seal
and replaced it with one
just called seal
. This is possible because Zig has a
sensible namespacing scheme, so users can call
it sodium.crypto_box.seal
or crypto_box.seal
or seal
depending on how
they import it, with no risk of collision.
The other topic I want to cover here is wrapping an interface in a higher level abstraction. This isn't strictly necessary, but since Zig's standard library knows about streams, we can make things like secretstream.zig's ChunkEncrypter which takes an output stream and a secret key and pushes fixed-sized chunks of encrypted data to it.
Conclusion
The benefits we can get from wrapping a C library in zig are similar to many higher-level-than-C languages, and some of those have easy-to-use FFIs. However, few are as effortless and I've yet to run into any that are as fun to use.