Mehdi Akiki
Rust Failure Atlas / Cargo and dependencies

RFA-692 · Case file with fixtures · Case 664 of 694 · Cargo workspace evidence

A Cargo Build Script Custom cfg Needs rustc-check-cfg

rustc-cfg activates a custom condition, while rustc-check-cfg declares its allowed name and values. A careful build script emits both, including the check declaration on branches where the condition is inactive.

Reviewed
Rust
Rust 1.98.1, Cargo 1.98.1, edition 2024
Targets
all Cargo targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
cargo::rustc-cfg activates a condition but does not register its name as expected; the build script omitted the separate cargo::rustc-check-cfg declaration.
First discriminating check
Run Cargo with the unexpected_cfgs lint denied, then pair every possible custom condition with an unconditional rustc-check-cfg instruction.

A build script can discover a native capability and enable Rust code with a custom configuration name. The surprising part is that enabling the name and declaring it valid are two different Cargo instructions.

The failing project prints cargo::rustc-cfg=has_fast_path. Rustc receives the condition, selects the fast branch, and still rejects both #[cfg(has_fast_path)] expressions because the crate denies unexpected_cfgs.

That sounds contradictory at first. It is not. One instruction chooses a configuration for this build. The other defines the vocabulary that source code is allowed to use.

Selection and validation are separate

I keep this small model in mind:

cargo::rustc-check-cfg=cfg(has_fast_path)  -> this name is expected
cargo::rustc-cfg=has_fast_path             -> this name is true now

The first line does not enable the fast path. The second line does not register the name with the checking system. The Cargo build-script reference documents the pair, and the rustc Cargo-specific check-cfg guide explains why they are used together.

This separation is useful. A name can be valid for the project while being false on the current machine. Without that distinction, the compiler could not tell a known inactive condition from a spelling mistake.

The declaration must be unconditional

The repaired project emits rustc-check-cfg before it emits rustc-cfg. More importantly, the declaration is not hidden inside the successful probe branch.

Suppose a script does this:

if accelerator_found() {
    println!("cargo::rustc-check-cfg=cfg(has_accelerator)");
    println!("cargo::rustc-cfg=has_accelerator");
}

The source still contains #[cfg(has_accelerator)] when no accelerator exists. On that machine the compiler sees no declaration and warns about the name. I emit every possible name and value unconditionally, then emit the active selection conditionally.

For a valued configuration, I declare the complete accepted set. This catches backend = "opnessl" instead of silently compiling the fallback for a typo. When values come from external tools, I decide whether the set is closed, admits none(), or must be represented by generated constants rather than an open-ended cfg string.

A Cargo feature is not always the replacement

The compiler help suggests considering a Cargo feature. Sometimes that is right: features are appropriate when the package consumer chooses a capability as part of dependency resolution.

A build script cfg answers another question. It can represent something discovered from the target, native SDK, generated binding set, or toolchain environment. Turning every discovered fact into a feature can make callers promise facts they cannot know and can create invalid combinations.

I choose by ownership:

  • a consumer-selected compile-time capability is often a feature;
  • a target or native probe result can be a build-script cfg;
  • a value needed by executable code may belong in generated Rust or rustc-env;
  • a runtime capability should usually remain a runtime check.

The name is not the architecture. It is only one channel from the build decision into rustc.

Keep the probe and fallback testable

A repaired build on one developer machine proves only the active branch. I test at least one environment where the probe succeeds and one where it fails. Both must accept the same cfg vocabulary, and each must select the intended code.

I also run with unexpected_cfgs denied in CI. The lint described in the rustc lint listing is valuable because an unknown cfg normally removes code. A misspelling can therefore look like a valid fallback rather than an obvious unresolved name.

For cross-compilation, the build script itself runs on the host while configuring code for the target. I do not use the build script's own cfg!(target_os) as proof about the target. I read Cargo's target environment and keep host tools separate from target capabilities.

The useful regression is negative

The fixture proves a negative property: a cfg can be active and still be undeclared. Its repair proves both sides by compiling with a denied lint.

For a real project I add another deliberate misspelling in a compile-fail test or a small generated fixture. If the misspelling is accepted, the checking contract is too broad. If a supported inactive value is rejected, the declaration is too narrow.

My review sequence is:

  1. list every custom cfg name and possible value the build script can emit;
  2. emit rustc-check-cfg for the complete list on every execution;
  3. emit rustc-cfg only for conditions that are true for this target build;
  4. deny unexpected_cfgs in a CI lane;
  5. exercise the positive, negative, and misspelled cases;
  6. decide whether each choice belongs to Cargo resolution, build discovery, generated code, or runtime detection.

The core principle goes beyond Cargo. Declaring a vocabulary and selecting one member are different operations. When both are explicit, a missing capability takes the fallback and a misspelled capability fails the build. That is exactly the distinction I want from configuration code.