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.

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-cfgpluscargo::rustc-check-cfgfor validated custom configurations;cargo::rustc-envfor compile-time environment values;cargo::rustc-link-searchandcargo::rustc-link-libfor native linking;- target-specific link arguments;
cargo::metadatafor immediate dependents of a package withlinks;- 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.
links makes native ownership a graph constraint
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:
- source and lockfile controlled;
- Rust toolchain and target components pinned;
- registry, Git, and native source mirrored or vendored;
- build tools, SDKs, sysroots, and environment pinned;
- filesystem and network access sandboxed;
- timestamps, paths, locale, randomness, and archive ordering normalized;
- 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:
- a host/target graph for the build script, generator, C compiler, linker, probes, and produced artifacts;
- an inventory of source revisions, digests, patches, headers, tools, SDKs, flags, environment, search paths, and licenses;
- exact rerun directives and an explanation of every remaining undeclared input;
- an
OUT_DIRgeneration design or a committed-bindings drift-check policy; - target-correct discovery and explicit static/dynamic, ABI, and runtime-link policy;
- a
linksownership and metadata contract, including override behavior; - a network-free acquisition and clean-room build procedure;
- a jobserver-aware concurrency plan with CPU and memory bounds;
- verbose logs, generated-file hashes, linker evidence, and artifact comparison from two builders;
- MSRV, current stable, host-target mismatch, supported-target, and failure-path tests;
- security controls for malicious schemas, archives, build output, paths, and secrets;
- 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/, includingportable-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.
Continue reading
Full table of contents