Managing Rust Dependencies with Nix, Part I

summary

Learn how to integrate Rust Cargo package manager with the Nix package manager.

Studio
10 min read

The Problem

Cargo is great. It has its quirks, but its compilation model is effective and for its use case it solves a lot of problems quite neatly — for example, its use of hashes to identify packages and allow multiple versions in one build product is very useful… though it can lead to some odd errors from rustc:

extern crate a;
extern crate b;

struct C;
impl a::A for C { }

fn main() { b::b::() }
Compiling c v0.1.0 (/tmp/err/c)                                                                                                       
error[E0277]: the trait bound `C: a::A` is not satisfied                                                                                 
 --> src/main.rs:8:5                                                                                                                     
  |                                                                                                                                      
8 |     b::b::();                                                                                                                     
  |     ^^^^^^^^^ the trait `a::A` is not implemented for `C`                                                                            
  |                                                                                                                                      
  = note: required by `b::b`

But we’re not here to talk about that. This post is about how to integrate Cargo with an external package manager, specifically Nix.

Why would one want to do that? Well, Cargo doesn’t handle everything.

For example, if you have a dependency written in some other language, say C or C++, and you want to link it in, Cargo won’t help you at all: the standard Cargo approach to external dependencies is to have a ‘shadow dependency’ that is a dummy Cargo package that is in charge of finding or building the real dependency, usually by trying a few heuristics to find the library, then falling back to downloading the source and applying similar heuristics to find a compiler. Cargo also isn’t very good at sharing builds between projects — meaning if you want a clean build of a particular project, you need to make a clean build of all its dependencies as well. In our case at Hadean, we have Rust programs embedded inside Nix, which we use to perform reproducible builds of Hadean Simulate and the Hadean Platform, which are written in a mix of Rust, C, and C++, and include build-time dependencies on external libraries and programs. Our development team runs a variety of different operating systems, including DebianArch LinuxVoid LinuxUbuntu, and NixOS.

As it stands, we have Meson invoking Cargo to perform the builds of Rust components of Hadean Simulate, and Cargo invoking (for historical reasons) CMake to build C and C++ components of Hadean; this build is invoked in a Nix shell that is responsible for ensuring its environment is consistent across all these different environments.

A full build therefore is quite slow, sometimes taking whole minutes: it includes from-source downloads and builds of all of the Rust dependencies of Hadean Simulate and the Hadean Platform, even when those dependencies are the same. It can also be fairly error-prone, as the interplay between Meson, Cargo, and CMake is quite subtle, and it’s easy to introduce non-determinism into the build or get into a situation where a component will not be built or rebuilt in some circumstances in which it really should. This model, in which a package is responsible for making sure its dependencies are available, is a foundation of many build systems, including Cargo (for non-Cargo dependencies), Python’s setuptools (and later Milksnake), Meson, and others. Nix turns this on its head, separating the dependency tree from the build responsibility tree. Indeed, in Nix the build responsibility tree is usually (notionally) flat. Nix can accomplish this by seeing packages not as complete values, as in other systems, but as functions from dependencies to ‘derivations’, or fully-instantiated package builds.

The package itself is then free to just assume that anything it needs will be available in the environment, without special handling; the location can be communicated to the build process using an environment variable, pkg-config, or other standard methods. In other words, Nix lets us make a build tree like this:

In this ‘tree’, no package is responsible for building any other package, including external dependencies of the Platform or Hadean Simulate. Instead, each package has its own build process, and packages are passed into their dependents by the top-level Nix expression; the build script of a package is lazily triggered when a dependent calls for the package, and cached for future builds or for sharing between components.

A Speedbump

So we just need to make Nix expressions for each of our dependencies, right? Well… not quite. The C and C++ dependencies are straightforward, but there is, at this juncture, essentially no such thing as a Rust dependency. While invoking rustc with requisite dependencies is relatively straightforward, the vast majority of Rust programmers use Cargo to package their code, and the only way to build it, therefore, is to do what Cargo does: there are only Cargo dependencies. Furthermore, Cargo is known to not play well with external package managers. Cargo wants to own the whole build process, from dependency resolution through dependency fetching and building all the way down to compiler invocation, and does not allow us to turn off any part of that process. The Cargo developers do not want to add the ability to provide external dependencies, citing potential ABI incompatibilities. Of course, in Nix, where the compiler is an input to the package like all its other build dependencies, this concern does not apply.

There have been two previous attempts to integrate Rust with Nix:

  1. buildRustPackage, the original Rust support in nixpkgs (the primary Nix package repository), simply invokes Cargo wholesale.
  2. buildRustCrate (and its accompanying project Carnix) is an alternative approach that attempts to build Rust packages as if they were built by Cargo — essentially emulating Cargo with bash scripts.

The first approach suffers from all the usual problems of invoking Cargo. Specifically, it is unable to understand anything that Cargo is doing: as Cargo is invoked as an opaque build command and allowed to run its course, Nix can’t do any dependency injection, and Cargo will simply download and build all the package’s dependencies as is it wont, duplicating the work for any Cargo packages built in this way.

The second approach attempts to provide a better experience, but Cargo is a large and complex beast, with many undocumented edge cases: it may not work for all Cargo packages, and may stop working as Cargo’s behaviour changes.

It would be nice to be able to use Cargo for both dependency resolution and build behaviour! Luckily, with a little hackery, we can do just that. The environment variable RUSTC allows us to override the Rust compiler invoked by Cargo; we can use it to insert arguments to the real rustc that provide the dependencies we want, so that we pre-empt only the dependency-provision behaviour.

Implementation

Let’s start by defining a fanciful expression for the simplest of Rust packages, hello:

{ callPackage }:
let
  mkRustCrate = callPackage ../lib/mkRustCrate { };
  fetchFromCratesIo = callPackage ../lib/fetchFromCratesIo { };
in
mkRustCrate rec {
  name = "hello";
  version = "1.0.4";
  src = fetchFromCratesIo {
    inherit name version;
    sha256 = "0kgyagy0xpzmb78wyfacnq33q85vndspaj610lhnm3qg1xk788jk";
  };
}

This gives us a basic structure for our mkRustCrate function, and now we can begin trying to make it work.

{ stdenv }:
{ name
, version
, src
, ...} @ args:
stdenv.mkDerivation ({
} // args)

First things first, we need to be able to download crates from crates.io and unpack them. Crates.io makes this fairly simple, providing an API of the form https://crates.io/api/v1/crates/${name}/${version}/download to fetch the crate ${name} at version ${version}. Unfortunately, our first attempt at this fails:

{ fetchzip }:
{ name, version, ... } @ args:
let
  args' = builtins.removeAttrs args ["name" "version"];
in
fetchzip ({
  name = "${name}-${version}.crate";
  url = "https://crates.io/api/v1/crates/${name}/${version}/download";
} // args')
$ nix-build -E '(import <nixpkgs> { }).callPackage ./. { }'
trying https://crates.io/api/v1/crates/hello/1.0.4/download
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
100   518  100   518    0     0    286      0  0:00:01  0:00:01 --:--:--     0
unpacking source archive /build/download
do not know how to unpack source archive /build/download
builder for '/nix/store/988yf7qsc4b8ddm36bzv978sdj9rzmva-hello-1.0.4.crate.drv' failed with exit code 1

The default nixpkgs unpackPhase uses the source name to determine how to unpack the file, and fetchzip doesn’t offer any way to change the name from crates.io’s default download. Luckily, there’s a hack for this: URI fragments are ignored by curl, so we can append a file extension in the URI fragment to let fetchzip know it can treat the file as a gzipped tarball.

fetchzip ({
  name = "${name}-${version}.crate";
  url = "https://crates.io/api/v1/crates/${name}/${version}/download#crate.tar.gz";
} // args')
unpacking source archive /nix/store/b7sj96fvx87xm057zr14xi01ml9y8ib6-hello-1.0.4.crate
source root is hello-1.0.4.crate
patching sources
configuring
no configure script, doing nothing
building
no Makefile, doing nothing
installing
install flags: install
make: *** No rule to make target 'install'.  Stop.
builder for '/nix/store/p36ma82qd7jb2vwvlh4xligxh7ibhq30-hello.drv' failed with exit code 2
error: build of '/nix/store/p36ma82qd7jb2vwvlh4xligxh7ibhq30-hello.drv' failed

Hooray, it works! Of course, it doesn’t build yet: we need to give a sensible buildPhase to override the default one that tries to call make. Let’s write a simple script to build the package:

mkdir cargo_home
export CARGO_HOME=$(pwd)/cargo_home

function run_cargo {
    local cmd=$1
    shift
    $cargo --frozen $cmd "$@"
}

buildFlags=()
if [ "x$buildProfile" = "xrelease" ]
then
    buildFlags+=(--release)
fi

[ "$doCheck" ] && run_cargo test || :
run_cargo build "${buildFlags[@]}"
[ "$doDoc" ] && run_cargo doc || :

And one to install it:

shopt -s nullglob

mkdir $out

cd "target/$buildProfile"

if stat -t *.rlib *.so *.a &>/dev/null
then
    mkdir $out/lib
    mv *.rlib *.so *.a $out/lib/
fi

And we’ll add a new variable to our environment to pass cargothrough, making mkRustCrate a function of it, as well as taking the new buildProfile argument:

{ stdenv
, cargo }:
{ name
, version
, src
, buildProfile ? "release"
, ... } @ args:
stdenv.mkDerivation ({
  inherit cargo;
  buildPhase = ". ${./build.sh}";
} // args)

And it works!

$ nix-build -E 'with import <nixpkgs> { }; callPackage ./. { }'
these derivations will be built:
  /nix/store/jkd090jabi4dlcvgh1rl9rrg1lpccnvz-hello.drv
building '/nix/store/jkd090jabi4dlcvgh1rl9rrg1lpccnvz-hello.drv'...
unpacking sources
unpacking source archive /nix/store/b7sj96fvx87xm057zr14xi01ml9y8ib6-hello-1.0.4.crate
source root is hello-1.0.4.crate
patching sources
configuring
no configure script, doing nothing
building
   Compiling hello v1.0.4 (/build/hello-1.0.4.crate)
    Finished release [optimized] target(s) in 0.20s
installing
post-installation fixup
shrinking RPATHs of ELF executables and libraries in /nix/store/dcpsddl001ys3mbb5pzpdl18wddybcgc-hello
strip is /nix/store/vcc4svb8gy29g4pam2zja6llkbcwsyiq-binutils-2.30/bin/strip
stripping (with command strip and flags -S) in /nix/store/dcpsddl001ys3mbb5pzpdl18wddybcgc-hello/lib
patching script interpreter paths in /nix/store/dcpsddl001ys3mbb5pzpdl18wddybcgc-hello
checking for references to /build in /nix/store/dcpsddl001ys3mbb5pzpdl18wddybcgc-hello...
/nix/store/dcpsddl001ys3mbb5pzpdl18wddybcgc-hello

$ ls result/lib
libhello.rlib

Of course, so far we’ve done nothing that wasn’t already done by buildRustPackage. In Part II we’ll introduce OpenSSL, a package with both Rust and non-Rust dependencies, and show how we can use Nix to provide them in a deterministic way.