Skip to content

The Rust Engineering Handbook / Chapter 42

Build Scripts, Native Dependencies, and Generated Code

Constrain Cargo build-time execution so native linking and generated code remain deterministic, cross-compilable, and diagnosable.

The source commit did not change, yet the release artifact did. One build host discovered /usr/lib/librelay.so; another downloaded a newer native archive; a third reused generated bindings left in the checkout. Cargo.lock was identical in all three runs.

Begin the investigation at the artifact and walk backward. Which linker inputs entered it? Which generated Rust was included? Which process produced those files? Which target was it reasoning about? Which files, environment values, tools, and network responses were inputs? If the build cannot answer those questions, its apparent automation has hidden part of the source.

A build script is host-executed code with authority over target compilation. Constrain it as a declared transformation:

(reviewed files, explicit environment, pinned tools, target facts)
                              -> build.rs on HOST
                              -> OUT_DIR files + Cargo instructions
                              -> crate and linker for TARGET

The practical invariant is that every output affecting compilation or linking is derived from reviewable inputs, written to the build output tree, and recoverable from diagnostics without network discovery or source-tree mutation.

The audit has three layers: first reconstruct when host code runs, then bound what it may read and write, and finally prove which instructions and native inputs crossed into target compilation. Keeping that order prevents a linker symptom from being mistaken for the source of nondeterminism.

Reconstruct the lifecycle before fixing the failure

When a package contains build.rs or names another script with package.build, Cargo compiles that script for the host and runs it before compiling the package for the target. The script’s current directory is the package root. Cargo supplies environment variables, captures output, and interprets stdout lines beginning with cargo:: as instructions.

This creates two compilation worlds during a cross-build:

  • build dependencies and the build script run on HOST;
  • ordinary dependencies and package targets compile for TARGET.

The build script’s own cfg!(target_os = "linux") describes the host on which the script was compiled. It does not answer whether the target crate is Linux. Read Cargo’s TARGET and CARGO_CFG_* environment values for target decisions. A macOS host producing a Linux binary must select Linux headers, libraries, ABI rules, and generated definitions even though the generator itself is a macOS executable.

The portability-build-lab records both dimensions in generated Rust:

let host = env::var("HOST").expect("Cargo sets HOST");
let target = env::var("TARGET").expect("Cargo sets TARGET");

The resulting binary prints them. When they differ, that is expected cross-compilation evidence, not an anomaly to erase.

A split build graph shows Cargo on the host compiling and running build.rs with declared schema, environment, tools, target facts, and native metadata; only OUT_DIR files and ordered Cargo instructions cross into the target lane, where rustc and the target linker create the artifact. Network and source-tree mutation are blocked outside the boundary.

Figure 42-1. Build-time authority lives on the host; target facts enter explicitly, and only declared outputs cross into target compilation.

Make reruns describe the real input set

Without any rerun directives, Cargo conservatively scans package files to decide whether to rerun a build script. That is broad, expensive, and still says nothing about undeclared external inputs. Emit at least one precise directive for every relevant file or environment value:

println!("cargo::rerun-if-changed=schema/protocol.txt");
println!("cargo::rerun-if-env-changed=RELAY_BUILD_FLAVOR");

The fixture reads exactly those inputs. Editing the schema reruns generation; changing the declared flavor reruns it; an unrelated README edit does not. Cargo automatically recompiles and reruns a changed build script, so listing build.rs itself is usually redundant.

Directories are allowed as rerun-if-changed paths, but they widen the invalidation surface. Prefer an explicit input manifest when a generator consumes many files. Generated discovery lists should themselves be deterministic, normalized, and reviewable.

rerun-if-env-changed concerns environment inherited by Cargo. It is not needed for Cargo-provided values such as TARGET, and it cannot make an inherently changing input reproducible. If PATH, locale, current time, hostname, Git state, or a compiler’s implicit search path affects output, either remove that dependency, pin and record it, or treat it as an explicit build input with a clean-room test.

Rerun declarations are invalidation rules, not a hermetic sandbox. A script can still read undeclared files or use the network. Pair declarations with execution controls and audits.

Keep generated code inside OUT_DIR

Cargo gives each build-script instance an OUT_DIR. Write generated Rust, bindings, object files, and intermediate artifacts there. Include generated Rust with a compile-time path:

mod generated {
    include!(concat!(env!("OUT_DIR"), "/build_contract.rs"));
}

Do not rewrite src/generated.rs. Source mutation dirties checkouts, races parallel builds, confuses editors and packaging, leaks one target’s output into another, and makes read-only or sandboxed builds fail. Committed generated code is a different policy: it can support review, bootstrap, or consumers without a generator, but then the repository needs a drift check that regenerates into a temporary output and compares results. The build should not silently rewrite the committed copy.

OUT_DIR can persist between runs. Do not assume it begins empty. Write complete outputs atomically where practical, remove obsolete files owned by the script, and avoid scanning arbitrary leftovers. Include enough generator version and input identity in diagnostics to explain what was produced.

Generated code remains code. Format or normalize it, test representative outputs, fuzz parsers when untrusted schemas enter, scan licenses for generated bindings, and review whether input data can inject tokens, paths, or linker arguments. Escaping text into Rust source requires a serializer, not string concatenation based on trusted-looking input.

Treat stdout as an ordered compilation interface

Build scripts communicate by printing instructions. Common outputs include:

  • cargo::rustc-cfg plus cargo::rustc-check-cfg for validated custom configurations;
  • cargo::rustc-env for compile-time environment values;
  • cargo::rustc-link-search and cargo::rustc-link-lib for native linking;
  • target-specific link arguments;
  • cargo::metadata for immediate dependents of a package with links;
  • warnings and errors for actionable diagnostics.

Instruction order can affect linker argument order. Emit search locations, objects, libraries, and dependent libraries in the sequence required by the target linker, and verify the final invocation with verbose logs. Avoid a generic rustc-flags escape hatch when a narrower instruction exists.

Normal Cargo output hides a successful script’s chatter. cargo build -vv shows build-script execution and instructions; Cargo also preserves stdout under a path such as target/debug/build/<package>/output. An incident run should capture the exact Cargo command, host, target, environment allowlist, tool versions, generated-file hashes, build-script output, linker command, and final artifact hash.

Do not emit secrets through warnings, generated source, environment constants, debug logs, or rerun records. Build logs are often retained and broadly visible.

A package declaring:

[package]
links = "relay_native_contract"

states that it owns the native library identity relay_native_contract. Cargo requires a build script and permits at most one package with a given links value in a resolved graph, helping prevent duplicate native symbols and conflicting ownership.

The owner can emit metadata:

println!("cargo::metadata=abi=fixture-v1");

An immediate dependent’s build script receives this as an environment variable derived from the links name, such as DEP_RELAY_NATIVE_CONTRACT_ABI. Metadata does not propagate transitively; create an explicit wrapper contract if a higher layer needs it.

The fixture declares links and transfers an ABI label without linking a system library. This isolates Cargo semantics from host tool availability. A real -sys crate must additionally answer:

  • Is source vendored, provided by the system, or selected by an operator override?
  • Which exact source revision, archive digest, patches, license, and build recipe apply?
  • Which compiler, archiver, bindgen tool, headers, SDK, sysroot, and flags are inputs?
  • Does discovery select the target library rather than a host library?
  • Are static/dynamic choice, ABI, runtime search paths, and symbol visibility explicit?
  • Can the build work offline from an approved source set?
  • Can downstream configuration override discovery without forking the crate?

System discovery is sometimes appropriate for distributions, but it exchanges artifact closure for an external platform contract. Record acceptable versions and discovery precedence; fail with the examined paths and target when the contract is not met. Vendored source improves closure but increases build time, patch ownership, license obligations, and toolchain requirements.

Cargo supports overriding build scripts for links packages through target configuration by supplying link and metadata values. This can let a controlled build environment avoid package discovery. Treat the override as versioned build policy and verify it against the package’s expected metadata contract.

Cross-compilation multiplies native mistakes

A build script can execute a host probe successfully and still configure the target incorrectly. Running a compiled test program during generation usually tests the host; the target binary might not be runnable. Prefer compile-time target facts, sysroot metadata, declarative configuration, or an emulator that is explicitly part of the build environment.

Separate tools by role:

Input Runs on Produces for
build.rs executable host Cargo instructions and generated files
code generator or bindgen host target-facing Rust or bindings
C/C++ compiler host process target objects
linker host process target artifact
generated executable probe target unsafe to run on host without an explicit runner

Feature and target logic from Chapter 41 belongs here as data. Build dependencies may have a different feature set from normal dependencies under modern resolvers. Target-specific native dependencies must follow the intended TARGET, not accidental host cfg. Test at least one host-target mismatch; same-host builds hide the category.

Hermeticity is a gradient with named exceptions

A fully hermetic build receives a closed input set and cannot observe undeclared machine state or network. Many Rust builds operate at intermediate levels. Make the chosen level explicit:

  1. source and lockfile controlled;
  2. Rust toolchain and target components pinned;
  3. registry, Git, and native source mirrored or vendored;
  4. build tools, SDKs, sysroots, and environment pinned;
  5. filesystem and network access sandboxed;
  6. timestamps, paths, locale, randomness, and archive ordering normalized;
  7. artifacts compared across clean builders.

--locked controls dependency resolution change. --offline prevents Cargo network access. Neither prevents build.rs from opening a socket, invoking an unpinned program, reading /usr/include, or embedding the clock. Use them as layers, not as a reproducibility claim.

Network access in build scripts is especially fragile: it bypasses Cargo’s source model, complicates credentials and proxies, makes old revisions unbuildable, and permits mutable responses. Fetch dependencies before the build through an authenticated, checksummed acquisition step; pass the resulting file as a declared input. If policy permits a network exception, name the endpoint, integrity mechanism, cache, retry/failure behavior, credentials boundary, and archival plan.

Coordinate parallel work through Cargo’s jobserver

Cargo and rustc coordinate process concurrency through a jobserver. A build script inherits one job slot and should use roughly one CPU unless it joins that protocol. Spawning nproc compiler jobs inside every script oversubscribes CI, causes memory pressure, and makes latency noisy.

Use tooling that honors Cargo’s jobserver, or integrate with the jobserver crate when parallel work is justified. The fixture only records whether CARGO_MAKEFLAGS is present; it does not consume tokens or spawn work. Keep generators sequential until measurement shows parallelism matters. Chapter 43 will treat wider profile and linker policy; the contract here is simply not to escape the build scheduler invisibly.

Audit the boundary with evidence

For portable-agent, the entire transformation is inspectable:

RELAY_BUILD_FLAVOR=hardened cargo +1.97.0 build -p portable-agent --offline -vv
cargo +1.97.0 run -p portable-agent --offline
find target/debug/build -path '*portable-agent*/output' -print
find target/debug/build -path '*portable-agent*/out/build_contract.rs' -print

The schema and flavor are declared inputs. HOST, TARGET, OUT_DIR, and links metadata come from Cargo’s build contract. Generated code stays beneath target/. The executable reports protocol, host, target, flavor, ABI label, and jobserver availability. Repeating an unchanged build provides rerun evidence; changing only the schema or flavor demonstrates the declared invalidation paths.

For stronger reproducibility evidence, start from two clean directories with the same controlled inputs, disable network, capture verbose logs, normalize known path-bearing diagnostics, and compare generated files plus final artifacts. If final binaries differ, use object-section, archive-member, symbol, and linker-map inspection to localize the cause. Do not paper over a difference by deleting timestamps until you know what semantic input they represent.

Failure signatures and repairs

The host probe. cfg!(windows) in build.rs selects Windows behavior while targeting Linux from Windows. Read TARGET or CARGO_CFG_TARGET_*.

The invisible download. A script fetches headers at build time. Move acquisition to a checksummed, cached, policy-controlled step.

The self-editing checkout. Bindings are written under src/. Generate in OUT_DIR, or commit and verify them through a separate drift workflow.

The permanent rerun. No directives cause broad package scans, or a changing output touches an input. Declare the minimal acyclic input set.

The stale output assumption. The script assumes OUT_DIR is empty and reads obsolete files. Own, overwrite, and clean a precise output namespace.

The accidental host link. Discovery finds /usr/lib for the host during a cross-build. Use target sysroots/configuration and verify the linker command.

The recursive build storm. Each native build spawns all host CPUs. Honor jobserver tokens and measure memory as well as CPU time.

The opaque failure. A script panics with “not found.” Report target, requested capability, searched controlled locations, and remediation—without secrets.

Exercise: audit a native binding build

Level: Audit. A telemetry-sys crate downloads a C library when pkg-config fails, runs bindgen, writes src/bindings.rs, enables static linking on Linux by default, and invokes make -j$(nproc). The product builds Linux from Linux and macOS hosts, supports an MSRV, and must build in a network-disabled release environment.

Deliver:

  1. a host/target graph for the build script, generator, C compiler, linker, probes, and produced artifacts;
  2. an inventory of source revisions, digests, patches, headers, tools, SDKs, flags, environment, search paths, and licenses;
  3. exact rerun directives and an explanation of every remaining undeclared input;
  4. an OUT_DIR generation design or a committed-bindings drift-check policy;
  5. target-correct discovery and explicit static/dynamic, ABI, and runtime-link policy;
  6. a links ownership and metadata contract, including override behavior;
  7. a network-free acquisition and clean-room build procedure;
  8. a jobserver-aware concurrency plan with CPU and memory bounds;
  9. verbose logs, generated-file hashes, linker evidence, and artifact comparison from two builders;
  10. MSRV, current stable, host-target mismatch, supported-target, and failure-path tests;
  11. security controls for malicious schemas, archives, build output, paths, and secrets;
  12. owners, exception expiry dates, and residual reproducibility risks.

Do not accept “Cargo.lock is unchanged” as evidence. The audit succeeds when another engineer can enumerate the build’s authority, reproduce its decisions, and diagnose a difference without guessing at host state.

Close the build-time authority gap

Review build scripts as production code and supply-chain executables. Require narrow inputs, target-correct facts, outputs confined to OUT_DIR, ordered and inspectable Cargo instructions, controlled native ownership, scheduler cooperation, and clean-room evidence. Prefer no build script when static source and manifest configuration suffice; every host executable removed is authority removed.

The dependency graph, feature set, target facts, generated code, and native linker inputs now form one reviewable build graph. The next chapter can tune profiles, linkers, cross targets, and reproducible artifacts without mistaking hidden discovery for configuration.

Sources and verification notes

  • Cargo Reference: Build Scripts, Build Script Examples, Environment Variables, Configuration, and Profiles.
  • Rust Reference: Conditional compilation, for the host/target meaning of compiled cfg predicates.
  • Executable source: examples/rust-engineering-handbook/part-07/portability-build-lab/, including portable-agent/build.rs, the schema input, native-contract-sys, and offline verification commands.
  • Fixture provenance records build output, generated files, links metadata, host/target values, current-stable behavior, and MSRV behavior under Cargo/Rust 1.97.0 and Rust 1.85.0.