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:

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:

  1. Install Rust and other dependencies/toolchains
  2. Run cargo build
  3. 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:

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-freebsd

Because 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_abort

The 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 -- --version

My 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:

  • version needs to be manually specified
  • sha256 for the source from crates.io needs to be specified
  • cargoHash needs 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 celq

This 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.

Avatar
Ivan Carvalho
Software Developer

Always looking for a challenge!

Related