RFA-694 · Case file with fixtures · Case 666 of 694 · Cargo workspace evidence
Cargo DEP_* Metadata Stops at the Immediate Dependent
Metadata from a package with links becomes DEP_* variables only for immediate dependent build scripts. An intermediate wrapper must consume, translate, or deliberately forward the contract.
- 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 exposes links metadata only to immediate dependent build scripts, so DEP_* values do not cross an intermediate wrapper automatically.
- First discriminating check
- Draw the package edges, locate the first build script receiving the DEP_* value, and forward only the metadata contract the next dependent really needs.
A -sys package can discover a native library and publish information such as an include path from its build script. Cargo turns this into a DEP_* environment variable, but it does not broadcast the value through the complete dependency graph.
The failing workspace has three packages:
app -> wrapper -> native-sys
native-sys declares links = "rfa_native" and emits an include metadata value. The wrapper is its immediate dependent and can receive DEP_RFA_NATIVE_INCLUDE. The app is one edge farther away. Its build script panics because that variable does not exist there.
DEP metadata follows one dependency edge
The Cargo build-script reference states that metadata is passed to immediate dependents, not transitive dependents. This is an important encapsulation boundary.
If all native metadata became global, distant packages would depend on implementation details they did not declare. Renaming a private sys crate or replacing it inside a wrapper could then break unrelated build scripts. One-edge delivery makes the receiving package decide what becomes part of its own public build contract.
The DEP_ name comes from the package's links value, uppercased and normalized, not simply from the Rust crate import name. That is why I inspect the manifest and actual build-script output rather than guessing the variable from use paths.
A wrapper should translate, not leak blindly
The repaired workspace gives the wrapper its own links identity and build script. The wrapper reads the native sys metadata because it is the immediate dependent, then emits the one value the app needs under the wrapper's namespace.
The app reads DEP_RFA_WRAPPER_NATIVE_INCLUDE, not the sys crate's private name. This creates an explicit contract at each edge.
I do not automatically forward every value. Native discovery can expose absolute paths, library order, debug flags, target assumptions, or details relevant only while compiling the wrapper. Forwarding all of it couples the application to the wrapper's internals and can make artifacts machine-specific.
A better wrapper may consume headers to generate safe Rust bindings and expose no path at all. If the downstream package really must compile related native code, I forward a small versioned set: perhaps include root, ABI version, and selected static/dynamic mode.
links is ownership, not a generic message bus
The Cargo manifest's links field says that a package links to a named native library. Cargo permits only one package with a given links value in the graph because competing owners could emit incompatible link instructions.
Adding links only to obtain DEP_* transport is therefore a design decision. The wrapper in the fixture uses it to demonstrate forwarding, but a production wrapper should claim a native identity only when that ownership is true.
Alternative transports include generated Rust APIs, an ordinary library function used by a build dependency, a configuration file with an explicit path, or moving the native compilation into the one package that owns it. Each choice has different host/target and rebuild behaviour.
Very verbose Cargo output is evidence
Normal Cargo output hides successful build-script stdout. With cargo -vv, I can see which script emitted each instruction. Cargo also stores output under its build directories.
When a DEP_* value is missing, my sequence is:
- draw only package edges and build-dependency edges involved in the native contract;
- identify the package declaring
links; - inspect its emitted metadata key exactly;
- locate the immediate dependent whose build script can receive it;
- decide whether that package consumes, translates, or forwards the value;
- verify host and target paths do not become mixed;
- rebuild from a clean target directory to exclude stale build-script output.
This is more reliable than exporting the variable globally from CI. A CI export can make one machine pass while package consumers still fail.
Test the graph shape, not only one workspace
Path dependencies in one repository can make ownership feel flatter than it is. Registry consumers see package boundaries, optional dependencies, target-specific edges, and possibly another version of the sys crate.
I keep a tiny three-package fixture because it proves the graph rule directly. I also test the feature combinations that add or remove the native edge. If static and dynamic modes emit different metadata, each receives its own assertion.
The core principle is capability locality. Build metadata belongs first to the package that directly asked for the native capability. Crossing another abstraction boundary requires a new deliberate contract. Cargo's non-transitive DEP_* rule forces that decision instead of turning the full dependency graph into one ambient build environment.