Skip to content

Instantly share code, notes, and snippets.

@hallettj
Created March 8, 2024 15:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save hallettj/870a697f48d2306915a5215208c80488 to your computer and use it in GitHub Desktop.
Save hallettj/870a697f48d2306915a5215208c80488 to your computer and use it in GitHub Desktop.
Building Rust binaries with Nix draft

#nix #rust

Packaging Rust Binaries

Requires flakes to be enabled. {.is-warning}

There are a few pieces that make up a complete Nix workflow for building Rust crates:

  • selecting a Rust toolchain
  • fetching dependencies
  • a derivation that builds the crate

This guide rust-overlay to select a toolchain. (For more background on that part see [[Rust DevShell]].) For the other parts this guide uses Crane.

Why Package with Nix?

Nix installs the exact Rust toolchain you want, plus pinned versions of any other build dependencies your project requires. It's like rustup, except that it manages all your build environment, not only Rust components. With Nix in the "Requirements" section of each of your READMEs you can just write, "Nix (with flakes enabled)". But you can get those advantages using a Nix devShell while building binaries with cargo build as usual, as is described in [[Rust DevShell|Development Environment for Rust]]. So what do you gain by moving the entire build process into Nix?

Reproducible Build-Time Dependencies

Sometimes there is some more setup that you need for building that are not represented in a devShell configuration. For example if you are using system libraries you often need pkg-config. Nix can handle this without having to document special setup steps in your README.

Easy Packaging

Adding a flake.nix file with a packages output to your repository is all you need to do to make your project consumable by Nix and NixOS users. Now you can pull your Rust app into any other project with a flake by referencing it in the flake inputs section. [Home Manager][] and [NixOS][] users can install your app the same way (if they use flake-based configs).

Cross-Compiling & Static Linking

These can be fiddly - there are environment variables and build-time dependencies. The Nix philosophy is to assume cross-compiling by default which can make things easier. And you can encapsulate all of the build-time details into a .nix file.

Smart Caching

In CI pipelines you have to take special steps to reuse caches between builds. Nix' binary caches generalize this to apply one caching strategy for all situations. No more looking up how to many caches for each different programming language that you work with.

Minimal Example

Let's use Nix to build rustfmt. Clone the repo, commit this flake.nix file, and run nix build --print-build-logs. The built store path will be symlinked to ./result.

Make sure to stage or commit flake.nix in your git repo. Nix ignores untracked files! {.is-warning}

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    # Provides a sensible list of systems to build outputs for, and can be
    # overridden by consumers who want outputs for other systems.
    # See documentation at https://github.com/nix-systems/nix-systems
    systems.url = "github:nix-systems/default";

    # Crane fetches cargo dependencies, and builds the crate.
    crane = {
      url = "github:ipetkov/crane";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Rust-overlay installs the exact Rust toolchain version that is requested.
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = inputs:
    let
      # Creates a function to define something separately for each output
      # system. For example,
      #
      #     packages = eachSystem (system: { default = {...} });
      #
      # evaluates to,
      #
      #     packages = {
      #       "x86_64-linux" = { default = {...}; };
      #       "aarch64-darwin" = { default = {...}; };
      #       # etc.
      #     };
      #
      eachSystem = inputs.nixpkgs.lib.genAttrs (import inputs.systems);

      # The overlay from rust-overlay adds `pkgs.${system}.rust-bin`. We have to
      # `import` because the overlay is defined in the `default.nix` file in the
      # rust-overlay repo instead of in `flake.nix`.
      overlay = import inputs.rust-overlay;

      # Defines a set of packages for each output system, and calls `extend` to
      # apply the overlay from rust-overlay
      pkgs = eachSystem (system:
        inputs.nixpkgs.legacyPackages.${system}.extend overlay
      );

      # Fetch the Rust toolchain specified in the repo's toolchain file. In this
      # case rustfmt requires Rust nightly with some specialized components
      # included.
      #
      # If you don't want to write a toolchain file you can use something like
      # `pkgs.${system}.rust-bin.stable.default` instead.
      rustToolchain = eachSystem (system:
        pkgs.${system}.rust-bin.fromRustupToolchainFile ./rust-toolchain
      );
    in
    {
      packages = eachSystem (system:
        let
          # Initialize Crane with the selected toolchain.
          craneLib = inputs.crane.lib.${system}.overrideToolchain rustToolchain.${system};
        in
        {
          # The "default" package is built by running `nix build`.
          default = craneLib.buildPackage {
            # Use the same directory that `flake.nix` is in as the source
            # directory for building. Source files are copied to a temporary
            # directory for building.
            #
            # `cleanCargoSource` filters source files to copy only commonly-used
            # Rust source files - `*.rs`, `*.toml`, `Cargo.lock`, and `.cargo/config`.
            # This avoids unnecessary rebuilds when other files in the repo
            # change. But you might need other files while building or running
            # test. See the Source Filtering heading later in this page.
            src = craneLib.cleanCargoSource (craneLib.path ./.);

            # Change to `true` to run `cargo test`. The build fails if tests
            # fail.
            #
            # The rustfmt tests require some files that are filtered out by
            # `cleanCargoSource` so this is disabled for the minimal example.
            doCheck = false;
          };
        }
      );

      # Set up a devShell that uses the same Rust toolchain that the crate is
      # built with. This is not required for building, but is helpful for
      # developing.
      devShells = eachSystem (system: {
        default = pkgs.${system}.mkShell {
          nativeBuildInputs = [
            rustToolchain.${system}
            # List other packages to make available in the devShell here.
          ];
        };
      });
    };
}

I promise this example looks more minimal without all of those comments!

Troubleshooting

"No such file or directory"

rustfmt-nightly> error: couldn't read src/format-diff/test/bindgen.diff: No such file or directory (os error 2)
rustfmt-nightly>    --> src/format-diff/main.rs:194:24
rustfmt-nightly>     |
rustfmt-nightly> 194 |     const DIFF: &str = include_str!("test/bindgen.diff");
rustfmt-nightly>     |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
rustfmt-nightly>     |
rustfmt-nightly>     = note: this error originates in the macro `include_str` (in Nightly builds, run with -Z macro-backtrace for more info)

See the Source Filtering heading below.

Source Filtering

TODO: source filter

Cross-Compiling

Static Linking

Under the Hood

Pure Dependency Fetching

Nix cares a lot about "purity". That comes with two practical aspects:

  1. A given Nix expression should produce the same store path content no matter where or when it is evaluated and built.
  2. After instantiating a derivation it is important to know whether a store path from a binary cache can be substituted instead of building from the derivation.

To enforce this Nix carefully limits network access while building. The content behind a URL can change over time so network fetches will break both of the requirements above without some guarantee that the content is stable over time.

Without Nix when you run cargo build cargo connects to crates.io, and downloads whatever crates are listed in Cargo.lock. That won't work with Nix because Nix will block those connections.

So how do we work around this? There are a few helpers in nixpkgs like fetchurl that can make network requests to download data to a store path. The trick is that these produce fixed output derivations which is where a derivation specifies the hash of the content of its store path. So to download something during build time you have to know the content hash of that thing in advance. This preserves purity because the content hash gives Nix a way to verify that the downloaded content is the same every time.

Therefore to fetch dependencies from crates.io you need to provide the hashes of each crate's tarball. Thankfully Cargo.lock provides this! If you take a look you'll see a lot of entries like this:

[[package]]
name = "serde-attributes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6eb8ec7724e4e524b2492b510e66957fe1a2c76c26a6975ec80823f2439da685"
dependencies = [
 "darling_core 0.14.4",
 "serde-rename-rule",
 "syn 1.0.109",
]

The checksum property is a SHA-256 hash of the .tar.gz file that you get when you download that crate version from crates.io. Part of what Crane does is to iterate through entries in Cargo.lock, and download and unpack each one to its own store path. (You can see the implementation here.) Those are the paths in your store with names that begin, cargo-package-.

A side-effect of downloading each crate to its own store path is that your Nix store, and any binary caches you push to effectively become mirrors of crates.io for the selection of crates that you are using. That might be advantage if you set up, for example, a shared binary cache on a fast, local network.

TODO: advisory-db TODO: multi-crate workspace TODO: static linking TODO: cross-compiling TODO: cargo boilerplate TODO: cache behavior TODO: build dependencies TODO: debug build TODO: doCheck / cargoCheckPhaseCommand

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment