Trying to support FreeBSD and Nix for my Rust CLI: Lessons Learned
Have you ever been curious about how Rust software is packaged with Nix? Did you ever want to ship binaries for FreeBSD? No? Well, I did.
In this post, bear with me as we explore:
- Building from source for FreeBSD, OpenBSD, and NetBSD
- Cross-compiling for FreeBSD x86-64 and aarch64
- Ways of writing Nix derivations for Rust
- Generating a Guix package from crates.io
As a reminder, this is a series of posts I made about packaging my Rust CLI:
- Part 2: Linux and macOS
- Part 3: FreeBSD, Nix, Guix (this post)
- Part 4: Windows multi-platform
There is also a Part 1, but let’s put it this way: it was written for another audience. If you are a Nix or BSD user, you might find it vile.
One of the reasons why I wrote this post was to share what I learned writing celq. It is a small tool that can query JSON, TOML, and YAML from the command line. Go check it out!
Disclaimers
Firstly, I have titled this post with the word “trying” because I did not have much of a clue of what I was doing before I tried doing it. If you find issues, reach out and I will try to fix them.
Secondly, this post focuses on self-distribution. Do not immediately submit ports to FreeBSD Ports or packages to Nixpkgs just because you can. There is a cost for the maintainers to keep the builds running. Let things happen organically: if users like your software, they will send a recipe themselves. As long as you make the software easy to build, you did your part.
FreeBSD from Source
I have always been fond of FreeBSD, so I wanted my code to support it. It might seem obvious, but the first step for supporting a new platform is to test your code there! It can be as simple as:
- Install Rust and other dependencies/toolchains
- Run
cargo build - Run
cargo test
There are two ways of doing the first step. One can install Rust from
the FreeBSD packages with pkg install rust or
rust-nightly. The alternative is installing
rustup-init from the repository and then downloading the
rest of the toolchain with rustup, as is usually done on other
platforms.
Afterward, the second step is to verify the code builds. In general, this step will go smoothly for Rust code, but there could be some friction, especially with system libraries or libraries that make low-level syscalls specific to Linux. This step is either trivial or requires investigation when the build fails.
Lastly, tests. This is similar to the previous step in the sense that tests either pass smoothly or identify problems that require manual fixes. Running tests can catch assumptions on Linux runtime behavior that don’t transfer to FreeBSD.
These steps need to run somewhere, so a few options for CI are:
- Cirrus CI
- GitHub Actions with virtualization
- Self-hosted runners with FreeBSD
I ended up using Cirrus CI as they offered first-class FreeBSD support. Their free tier was sufficient for my project, but their pay-as-you-go model seemed reasonable. I set it up to only run full builds on commits to main that change Rust files to help lower the resource usage. GitHub Actions do not support native FreeBSD runners, but there are many actions wrapping QEMU around. I use those for smaller tasks we will discuss later on.
Following these steps ensures that your project plays nicely with the Rust section of the Porter’s handbook. It will simplify the life of any future port maintainer.
Cross-compiling FreeBSD binaries
Next, let’s talk about pre-built binaries. FreeBSD users can always install from source as discussed previously, but for convenience, it is still interesting to provide binaries.
Even if you are not keen on keeping a FreeBSD CI, cross-compilation to FreeBSD from Linux has become easier recently. cargo-zigbuild, which we discussed during Part 1, has reduced the friction.
The idea behind cargo-zigbuild is as follows: Zig has a great linker and it ships with the FreeBSD libc headers. That would be handy for cross-compilation! Indeed, take a look at this command:
cargo zigbuild --locked --release --target x86_64-unknown-freebsdBecause Linux runners are more abundant, I found it easier to set up
Zig and cargo-zigbuild with the x86_64-unknown-freebsd
target than spinning up QEMU in GitHub Actions.
For aarch64 binaries, the story is slightly different as
aarch64-unknown-freebsd is a tier 3 target. If you just
copy the setup from x86-64, you will end up with an error message like
this:
error: component 'rust-std' for target 'aarch64-unknown-freebsd' is unavailable for download for channel '1.89.0'
The easiest solution for cross-compilation is to switch to Rust nightly and use the unstable build-std feature:
cargo +nightly zigbuild \
--target aarch64-unknown-freebsd \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abortThe above command requires the rust-src component from
rustup and takes longer as it needs to compile std. But it gets the job
done.
OpenBSD and NetBSD
I will briefly discuss OpenBSD and NetBSD, but I will disclose I have never used them on a daily basis.
If you want to support these systems, I strongly recommend following
the build-from-source approach discussed previously. Install Rust with
the package manager, run
cargo build/cargo test to catch bugs, and act
accordingly to close bugs.
I was not convinced pre-built binaries would add much value for these platforms. Nevertheless, testing OpenBSD/NetBSD in CI via virtualization goes a long way toward preventing regressions for future port maintainers.
Nix from your repository
NixOS is one Linux distribution that is different from others. Nix can also be used outside of NixOS like in Home Manager. For those not familiar with it, Nix uses a declarative build system with a functional programming language.
In contrast to the previous blog post where we shipped pre-built binaries for Linux, Nix users expect a Nix derivation. The derivations make the system state reproducible and easier to inspect.
There are multiple ways of writing Rust derivations for Nix. At the
time of writing, I learned that the NixOS wiki listed seven
different methods. I used the built-in
buildRustPackage, although crane seems to be a
popular option as well.
For me, the best way to learn the derivation was to go check how other Rust CLIs did it in Nixpkgs. I looked at jaq’s entry, because celq was inspired by jaq.
The tweaks I made were:
Automatically reading the version
Nix can read your Cargo.toml! You can write the file
once and forget it, there is no risk that the version will get out of
sync.
let
cargoToml = builtins.fromTOML (builtins.readFile Cargo.toml);
in
rustPlatform.buildRustPackage (finalAttrs: {
pname = "celq";
version = cargoToml.package.version;
}Referring to the local source
It is possible to refer to the source of your library without
fetchFromGitHub or fetchCrate. That minimizes
the burden of updating sha256 checksum field during
updates.
src = lib.cleanSource ./.;Avoiding cargoHash
The concept of having Cargo.lock published with your
crate fits nicely with the idea of reproducible builds for Nix. If you
specify:
cargoLock = {
lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true;
};You avoid the need to update cargoHash each time you
modify your dependencies.
In practice
You can take a look at celq_dev.nix and flake.nix to see the end result, but I managed to achieve this:
nix run github:IvanIsCoding/celq#dev -- --versionMy dev derivation runs the code from the latest commit
from main. It also doesn’t need to be updated, which makes it
low-maintenance.
Nix from crates.io
Now that we’ve discussed the first derivation, let’s dive into the second derivation, which was the one I wrote first.. I talked with some Nix users and the previous one is the more idiomatic one.
With that being said, I found this derivation useful as it can be
tweaked to package any tool published in crates.io. Mentally, it was
also comforting to have a derivation pinned to a published, stable
version in case I messed up the state of the main during
development.
You can see the derivation in celq.nix. It “undoes” some of the conveniences we discussed previously:
versionneeds to be manually specifiedsha256for the source from crates.io needs to be specifiedcargoHashneeds to be specified and updated for each version
Keeping these three fields up-to-date can be slightly annoying, in
particular cargoHash as it is kind of hard to calculate the
hash without running nix first. Nevertheless, the code runs
with nix run github:IvanIsCoding/celq and it will not break
as long as I point it to a stable crates.io release.
Guix
Last but not least, Guix. This is another package manager based in functional programming. The language though is different: Guix uses Guile.
I must say that Guix does have excellent documentation. To package
celq for Guix, I followed the steps from the cookbook:
guix import crate celq
guix import --insert=packages/celq.scm \
crate --lockfile=../celq-repo/Cargo.lock celqThis generated celq.scm, which can conveniently be distributed on a custom Guix channel.
I found the Guix tooling interesting because the file did convert
Cargo.lock ahead of time, so there were many statements
like this:
(define rust-cel-0.11.6
(crate-source "cel" "0.11.6"
"0pyrqas0r64wykydfmgqfjyb1c14x4w07mafk6fmakzvhb8b7plf"))Unfortunately, the Rust version that shipped with Guix was not recent
enough to compile my dependencies. This was fixable with some
interesting tricks. Guix can patch Cargo.toml, so naturally
I tried to lower the MSRV:
substitute* "Cargo.toml"
;; Patch rust-version
(("rust-version = \"[^\"]+\"")
"rust-version = \"1.85\"")I applied the same strategy to downgrade the dependencies, reran
guix import crate based on a Cargo.lock with
downgraded dependencies, and kept going. Eventually, things compiled
after a couple of attempts.
I do not plan to keep my Guix channel up-to-date, as I have not found a programmatic way of applying the patches after updates. Nevertheless, I enjoyed going through the process and getting a successful build.
What’s Next
The last post of this series will cover Windows. It will also discuss multi-platform tools I have not got a chance to cover such as Mise. In addition to some practical topics like Scoop, I will also cover less practical experiments like targeting MS-DOS and Windows 98. Stay tuned.