Skip to content
rusthacks.dev
Security

Reproducible Builds in Rust and Verifying a Binary

A reproducible build produces the same bytes from the same source anywhere. See what breaks it in Rust (paths, timestamps, toolchains), how to fix each, and how to verify a binary.

14 min read
Summarize in AI
Flat illustration for the post “Reproducible Builds in Rust and Verifying a Binary”, composed around deploy.

A reproducible build means that building the same source twice produces the same binary, bit for bit. It sounds like a nicety until you need it. If you publish a binary, users can only trust it if someone else can rebuild it from the source and get the same bytes. If you ship firmware, an auditor can confirm that what runs on the device came from the reviewed commit. And if a build ever differs from the last one without a code change, a reproducible pipeline tells you immediately instead of leaving you to wonder what moved.

Rust starts in a good position. The compiler is deterministic, and a plain release build of a plain crate is identical across directories and machines of the same platform. The trouble comes from the things you add: timestamps, absolute paths, debug information, different compiler versions and build scripts that read the environment. This guide tests each one on a real crate by building it in two different directories and comparing SHA-256 hashes, so you can see which sources of non-determinism are real, which are not, and what fixes them.

The experiments ran on Rust 1.99.0 stable and a 1.101.0 nightly, on Apple silicon macOS, with the crate copied to /tmp/rb1 and /tmp/rb2. One caution before the results: the behavior of debug information and paths is platform-specific, and macOS is the awkward platform. The Linux results will be kinder on some rows, and the guide says where it expects that without claiming it was measured.

The test: build twice, compare

The method is the same for every experiment, and it is the one you should run on your own project. Copy the source to two different directories, build each from a clean target, and hash the outputs.

Shell
cp -r /tmp/rb1 /tmp/rb2
(cd /tmp/rb1 && cargo build --release --locked) && (cd /tmp/rb2 && rm -rf target && cargo build --release --locked)
shasum -a 256 /tmp/rb1/target/release/repro /tmp/rb2/target/release/repro

Two details deserve a sentence. Different directories are the point: a build that only reproduces in the same path has not proved much, since the next developer will not have your path. And --locked makes Cargo fail if the lockfile would change, which brings us to the first thing a reproducible build needs.

Pin the dependencies

If Cargo.lock is missing, cargo build --locked refuses to run:

Text
error: cannot create the lock file /private/tmp/rb1/Cargo.lock because --locked was passed to prevent this
help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.

That error is the system working. Without a lockfile, each build resolves the newest compatible version of every dependency, so a build next month differs from today's by whatever was published in between. The fix is to commit Cargo.lock (for binaries, always; for libraries, optional, but harmless) and to build with --locked in CI, so a build that would silently update dependencies fails instead. For stronger guarantees, cargo vendor copies all dependency source into your repository, which removes the network from the build and protects against a registry outage or a yanked crate.

The plain case reproduces

Here is the program under test:

src/main.rsrust
fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() > 5 {
        panic!("too many arguments");
    }
    println!("repro {}", env!("CARGO_PKG_VERSION"));
}

It includes a panic!, because panic messages carry file paths and are the usual way for a source path to leak into a binary. Built in the two directories with default release settings and the lockfile committed:

Text
130d0a8c1dab4eb3 /tmp/rb1/target/release/repro
130d0a8c1dab4eb3 /tmp/rb2/target/release/repro

The hashes are identical. The panic location is recorded as src/main.rs, relative to the package root, not as /tmp/rb1/src/main.rs, so the directory name never enters the binary. For plain Rust code in a release build, reproducibility costs nothing. The rest of this guide is the list of ways to lose it.

Embedding an absolute path

The first way is to put one in yourself. env!("CARGO_MANIFEST_DIR") expands at compile time to the absolute path of the package, and programs use it for locating resources or printing build info.

src/main.rsrust
println!("repro {} built from {}", env!("CARGO_PKG_VERSION"), env!("CARGO_MANIFEST_DIR"));

The two builds now hash differently, as they must, since the string differs. The instinctive fix, the compiler flag --remap-path-prefix, which rewrites path prefixes recorded in the output, does not help here:

Text
embeds CARGO_MANIFEST_DIR                            ea4a9595e525 9f5c0b0a8ed3  DIFFERENT
same, with --remap-path-prefix                       291912afe02a 20d56c38634b  DIFFERENT

The flag rewrites paths that the compiler itself records, in panic locations and debug information. It does not touch the value of an environment variable that your code asked for. The only fix is to stop embedding it: use a relative path, compute the location at run time, or take the value from a variable you control, like std::env::var("APP_DATA_DIR").

Debug information and paths

The second source is debug information, which records the source path of every function so a debugger can show it. Enabling debug = true in the release profile, a common choice so stack traces and profilers work on production binaries, changes what the build records. On this macOS machine, the builds were no longer identical:

Text
debug = true, no remap                               d9c1eb00551c 3dac6490e6c5  DIFFERENT
debug = true, with --remap-path-prefix               ac6931ffdbd4 53e954fbff53  DIFFERENT

Remapping did not rescue it on macOS. The Mach-O linker records the location of each intermediate object file in the debug map, and those objects live in target/ under the build directory, which --remap-path-prefix does not rewrite. Two options did produce identical binaries: stripping, and not keeping debug information in the executable at all.

Text
debug = true + strip = true, no remap                eedb2e084448 eedb2e084448  SAME

strip = true in the profile removes symbols and debug information from the shipped file, and the two builds hashed the same. The other rows tried, debug = "line-tables-only" with and without remapping, and split-debuginfo = "packed" with remapping, were all still different on macOS. On Linux the usual recipe is different and kinder: pass --remap-path-prefix=$PWD=/build (and likewise for $CARGO_HOME and $RUSTUP_HOME) so the debug info records fixed paths, and the build is reproducible with the debug information intact. That recipe was not measured here, and the macOS result is the reason to test it on the platform you ship for. If you need symbols for debugging, ship the stripped binary and archive the unstripped one, or the separate debug file, alongside the release.

The remap flags worth knowing, and the rule that makes them reliable, is to map every absolute prefix that can appear to a fixed one: the project directory, the Cargo registry ($CARGO_HOME/registry/src/... shows up in panic messages from dependencies), and the Rust standard library source (/rustc/<hash>/... is already fixed, because the toolchain builds it that way). On Linux the invocation looks like this, and as noted it was not measured in this lab:

Shell
RUSTFLAGS="--remap-path-prefix=$PWD=/build --remap-path-prefix=$HOME/.cargo=/cargo" \
  cargo build --release --locked

The cleanest design is to build in the same path every time, a container with the source mounted at /build, so there is nothing to remap in the first place.

Timestamps in build scripts

The third source is the clock. Build scripts often stamp the binary with the build time, so --version can say when it was made. That is incompatible with reproducibility by definition, and the experiment shows it:

build.rsrust
use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    println!("cargo:rustc-env=BUILD_TIME={now}");
}
Text
build.rs stamps SystemTime::now()                    b0f822be1688 fd382ed0e309  DIFFERENT

The standard fix is the SOURCE_DATE_EPOCH environment variable, a convention from the Reproducible Builds project: if it is set, use that timestamp instead of the clock. CI sets it to the commit time (git log -1 --format=%ct), and the stamp becomes a property of the source, not of the moment of the build.

build.rsrust
use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    // Honor the reproducible-builds convention: a fixed timestamp wins over the clock.
    let stamp = match std::env::var("SOURCE_DATE_EPOCH") {
        Ok(v) => v,
        Err(_) => SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().to_string(),
    };
    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
    println!("cargo:rustc-env=BUILD_TIME={stamp}");
}

With SOURCE_DATE_EPOCH=1767225600 exported (that is 2026-01-01 at midnight UTC), the two builds matched:

Text
same, with SOURCE_DATE_EPOCH=1767225600 set          c7f7d641fa52 c7f7d641fa52  SAME

and the program printed repro 0.1.0 built at 1767225600. The cargo:rerun-if-env-changed line matters: without it, Cargo caches the build script's output and does not notice that the variable changed. The same discipline applies to anything else a build script reads from the environment, such as the hostname, the user name, the Git commit (acceptable if it is the commit being built), or the current directory. Each is an input to the build, and each must be either fixed by your pipeline or left out.

The compiler version is an input too

The last and most obvious input is the toolchain. The same source built with stable 1.99.0 and with the 1.101.0 nightly produced different binaries, and a rebuild with stable 1.99.0 reproduced the first one exactly:

Text
stable   d8c3304756c8  (rustc 1.99.0)
nightly  88fa9695e29d  (rustc 1.101.0-nightly)
stable   d8c3304756c8  (rustc 1.99.0)

So pin the compiler, which means a rust-toolchain.toml with an exact version (channel = "1.90.0", not stable), as covered in rustup explained. A moving stable channel changes your binaries every six weeks without a commit. The linker and the C compiler are also inputs for any crate with a build script that compiles C, and so are the system libraries you link against. The most reliable way to pin all of them at once is to build inside a container image referenced by digest, not by tag, since a tag can be repointed:

DockerfileDockerfile
FROM rust:1.90.0-bookworm@sha256:REPLACE_WITH_THE_DIGEST_YOU_VERIFIED AS build
WORKDIR /build
COPY . .
ENV SOURCE_DATE_EPOCH=1767225600
RUN cargo build --release --locked --target x86_64-unknown-linux-gnu \
 && strip target/x86_64-unknown-linux-gnu/release/repro

The digest placeholder is deliberate: look up the real digest with docker buildx imagetools inspect rust:1.90.0-bookworm and write it in, so that a rebuild next year starts from the same bytes. Because the container always uses /build as the working directory, no path remapping is needed.

The Rust-specific trap: HashMap in a build script

One source of non-determinism is particular to Rust and catches careful engineers. The standard HashMap uses a randomly seeded hasher, so iterating it gives a different order in every process. That is a deliberate protection against hash-flooding attacks, and it is fine at run time. In a build script, a procedural macro or a code generator, iteration order becomes part of the output. A script that generates a lookup table from a HashMap writes the rows in a different order on every build.

build.rsrust
use std::collections::HashMap;
use std::{env, fs, path::PathBuf};

fn main() {
    let mut codes: HashMap<&str, u16> = HashMap::new();
    for (name, code) in [("ok", 200), ("created", 201), ("not_found", 404), ("teapot", 418), ("gone", 410)] {
        codes.insert(name, code);
    }

    // Iterating a HashMap: the order differs from process to process.
    let mut out = String::from("pub static CODES: &[(&str, u16)] = &[\n");
    for (name, code) in &codes {
        out.push_str(&format!("    (\"{name}\", {code}),\n"));
    }
    out.push_str("];\n");
    fs::write(PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("codes.rs"), out).unwrap();
}

Building that crate in two directories gave two different binaries, with no timestamp, no path and no difference in the toolchain anywhere. Swapping HashMap for BTreeMap, which iterates in sorted key order, made them identical:

Text
build.rs writes a table from a HashMap               5fe2b44641b5 7c591f249b55  DIFFERENT
same, with BTreeMap (sorted order)                   699cfb07fc0c 699cfb07fc0c  SAME

and the generated file came out the same on every build, sorted by key:

Text
pub static CODES: &[(&str, u16)] = &[
    ("created", 201),
    ("gone", 410),
    ("not_found", 404),
    ("ok", 200),
    ("teapot", 418),
];

The rule is mechanical: any code that runs during the build and writes output must iterate collections in a defined order. Use BTreeMap and BTreeSet, or collect into a Vec and sort() before writing. The same applies to reading directories, since std::fs::read_dir returns entries in whatever order the file system chooses; sort the paths before processing them. Dependencies that generate code at build time (parser generators, protobuf compilers, bindings) are covered by the same rule, and a reproducibility failure that you cannot explain is often one of them, so diff the generated files in target/release/build/*/out/ between the two builds first.

Incremental builds and caches are not the proof

A common mistake is to check reproducibility by rebuilding in the same directory and seeing the same hash. That proves little: Cargo's cache can return the previous artifact, and the second build may not have run the compiler at all. Always test from a clean target, in a different directory, ideally on a different machine. For Cargo itself, release profiles disable incremental compilation by default, which is the right setting for reproducibility; if you turned it on for speed with incremental = true, turn it off in the profile you ship, because incremental artifacts can carry state from earlier builds.

Parallelism is not a concern for Rust's output: the compiler splits crates into codegen units and compiles them concurrently, and the result does not depend on scheduling, which is why the plain case at the top of this guide matched exactly. That determinism is a property the compiler team tests, and it is the reason the rest of this guide is about your inputs and not about the compiler's behavior.

Dependencies and the registry

--locked pins versions, and it does not by itself protect you from a registry serving different content for the same version. Cargo verifies each downloaded crate against the checksum recorded in Cargo.lock, so a tampered crate fails the build, which covers most of the risk. For builds that must not touch the network, cargo vendor writes all dependency sources into a vendor directory, and a .cargo/config.toml entry redirects the registry to it:

.cargo/config.tomlTOML
[source.crates-io]
replace-with = "vendored-sources"

[source.vendored-sources]
directory = "vendor"

Commit the directory or archive it with the release, and build with --frozen (which implies --locked and --offline). Now the build has no external inputs left except the toolchain, which the container image pins. Vendoring also makes it possible to audit exactly the source that went into the binary, which is the point of the exercise.

A verification routine you can automate

Reproducibility is a claim, and claims need a check that fails when they stop being true. The routine has three parts. Build twice in separate clean directories (or two separate containers) in CI, and compare hashes; fail the job if they differ. Publish the hash with every release, next to the download, so that anyone can rebuild and compare. And when a comparison fails, find the difference rather than guessing: cmp -l a b | head lists the first differing bytes, and diffoscope (a tool from the Reproducible Builds project) unpacks archives and disassembles code to show a readable difference. Strings extracted from both files with strings and compared with diff often find the leaked path or timestamp in a minute.

Shell
a=$(cd /build-a && cargo build --release --locked -q && shasum -a 256 target/release/repro | cut -d' ' -f1)
b=$(cd /build-b && cargo build --release --locked -q && shasum -a 256 target/release/repro | cut -d' ' -f1)
[ "$a" = "$b" ] && echo "reproducible: $a" || { echo "MISMATCH $a $b" >&2; exit 1; }

What reproducibility does and does not give you

A reproducible build proves the binary matches the source. It does not prove the source is free of vulnerabilities, or that the dependencies you pinned are trustworthy, or that your build machine was not compromised in a way that affects both builds identically. It is one link in a chain that also includes dependency auditing (cargo audit and cargo deny), code review and signing. Its particular value is that it lets a second party check the chain without trusting the first.

When to stop chasing it

Perfect reproducibility across operating systems and architectures is not realistic: a macOS build and a Linux build are different programs, so the goal is "same inputs, same platform, same bytes". For an internal service deployed from your own CI, a pinned toolchain, a committed lockfile and --locked give most of the benefit, and verifying bit-for-bit identity is optional. For a binary others will trust, a security tool, a package in a distribution, a wallet, a firmware image, the full routine is worth it. If your build downloads anything at build time that is not pinned by hash, fix that first, because no amount of path remapping rescues a build that fetches the latest of something.

A checklist, in the order that finds problems fastest

Run these against your own project, top to bottom, and stop at the first difference between two clean builds in different directories.

  1. Is Cargo.lock committed, and does CI build with --locked?
  2. Is the toolchain pinned to an exact version in rust-toolchain.toml or by the container image digest?
  3. Does the release profile strip symbols, or does your platform's remap recipe make debug information reproducible?
  4. Does any code embed env!("CARGO_MANIFEST_DIR"), file!() of a generated path, the hostname, the user name or the clock?
  5. Do build scripts and code generators iterate only sorted collections and honor SOURCE_DATE_EPOCH?
  6. Are native dependencies, the C compiler and the linker pinned by the build image?
  7. Does CI build twice and fail on a hash mismatch, and does the release page publish the hash?

If the first two pass and the build still differs, the cause is almost always item 4 or 5, and a diff of the strings output of both binaries will show it in under a minute. Treat the check as ordinary test infrastructure: it costs one extra build per release and it catches the regressions that nobody would otherwise notice until someone outside the team tried to verify a download.

Also inDevOps
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.