· 6 min read ·

Rust's WebAssembly Linker Is Shedding a Decade of Silent Permissiveness

Source: rust

The Rust project recently announced a breaking change to all of its WebAssembly targets: the --allow-undefined flag will no longer be passed to wasm-ld during linking. For many projects this will be a non-event, but for others it will surface latent bugs that have been lurking under the surface for years.

This is worth understanding beyond the migration checklist level, because it touches something fundamental about how WebAssembly’s module model differs from every native target Rust has ever supported.

What WebAssembly Does With “Undefined” Symbols

On a native target, an undefined symbol at link time is almost always an error. Either the symbol will be resolved from a shared library at runtime, or the program is broken. The linker is the last line of defense.

WebAssembly operates on a different principle. A .wasm module is not a self-contained executable in the traditional sense; it is closer to a component that declares what it needs from its host environment and what it provides in return. These host-provided dependencies are called imports, and they are a first-class concept in the WebAssembly binary format. When a wasm module is instantiated, the host (a browser, a runtime like Wasmtime, or a Node.js process) satisfies those imports before execution begins. If an import is missing, instantiation fails with a clear error.

So an “undefined” symbol in wasm-ld does not mean the same thing it does in ld. It means: this symbol should become a wasm import, provided by whatever host loads this module. That conceptual translation is what --allow-undefined was automating.

How the Flag Got Added in the First Place

When Rust first introduced the wasm32-unknown-unknown target, the workflow for building browser-facing WebAssembly was fundamentally different from what it is today. JavaScript interop was done through hand-written extern "C" blocks that referenced functions the JS glue code would provide. Tools like wasm-bindgen eventually automated most of this, but the underlying mechanism remained the same: Rust declares a foreign symbol, and whoever links the final module into a web page satisfies it.

Without --allow-undefined, wasm-ld would error on every single one of those extern declarations. Passing the flag was the pragmatic move that made the whole ecosystem work. The cost was that the flag used --unresolved-symbols=ignore-all, meaning wasm-ld would silently discard any undefined symbol, including genuine mistakes, typos in function names, or removed APIs that nobody noticed were still referenced.

What Proper Import Declaration Looks Like

Rust has had a better mechanism available for a while. The #[link(wasm_import_module)] attribute lets you declare, at the source level, that a block of foreign functions should be imported from a specific named module:

#[link(wasm_import_module = "my_host_env")]
extern "C" {
    fn host_log(ptr: *const u8, len: usize);
    fn host_now_ms() -> f64;
}

This produces a wasm module that explicitly imports host_log and host_now_ms from the my_host_env module. The import section in the binary is populated correctly, the host knows what to provide, and nothing is left ambiguous. Forgetting to provide this attribute, or misspelling the function name, becomes a link error rather than a silent omission.

For functions that should be imported from the default import namespace, the wasm_import_module can be set to "env", which is the conventional catch-all module name used by many runtimes.

#[link(wasm_import_module = "env")]
extern "C" {
    fn malloc(size: usize) -> *mut u8;
}

The Rust Reference covers the link attribute, but the wasm-specific behavior has historically been less prominent in the documentation than it deserves.

The Comparison With Emscripten and C

C and C++ targeting WebAssembly via Emscripten handle this differently. Emscripten runs a whole-program transformation pass that scans all function references and generates JavaScript stubs for anything not satisfied internally. The --allow-undefined gap never mattered much there because Emscripten’s toolchain fills it automatically at a higher level.

Zig’s wasm support has been explicit about import modules from early on, which is consistent with Zig’s general philosophy of making foreign interactions visible in the type system rather than implicit in build configuration.

Rust’s approach has historically been to let wasm-bindgen and the build tooling handle the messy parts, which worked well for the browser use case but created an invisible dependency on --allow-undefined for anyone doing lower-level embedding.

What Actually Breaks

Projects most likely to be affected fall into a few categories.

First, code that uses extern "C" blocks to reference host-provided functions without #[link(wasm_import_module)]. These symbols will now cause a link error. The fix is to add the attribute with the appropriate module name.

Second, C libraries brought in via cc-rs or similar that make assumptions about what symbols the host will provide. These can be trickier because the undefined symbols are in the compiled C code rather than the Rust source, and you may need to pass linker arguments to restore the old behavior for specific cases.

Third, crates that were depending on the permissive behavior to link against intrinsics or builtins that they expected the runtime to supply. The right fix here is usually to be explicit, but an escape hatch exists: -C link-arg=--allow-undefined can be passed to rustc to restore the old behavior while you work through a proper migration.

For wasm-bindgen users, the maintained versions of the crate already use proper import annotations and should not be significantly affected, provided you are on a reasonably recent version.

The WASI Targets

The wasm32-wasip1 and wasm32-wasip2 targets have a somewhat different story. WASI defines a specific set of host imports through the WASI interface specification, and the wasip2 target in particular leans heavily on the WebAssembly Component Model, which has its own mechanism for declaring imports through WIT (WebAssembly Interface Types). For those targets, --allow-undefined was less of a crutch because the import surface is more formally constrained, but removing it still has correctness benefits: a WASI binary that accidentally references something outside the WASI spec should fail at build time, not at runtime on a host that happens to be more or less permissive.

Why This Is the Right Call

The core issue with --allow-undefined is that it conflates two things: the legitimate need to declare host imports, and the convenient suppression of linker errors. These are not the same thing, and treating them as equivalent has cost Rust’s wasm users real debugging time.

Consider what happens today if you rename a host function and forget to update one of your extern "C" declarations. The binary compiles and links cleanly. The wasm module might even instantiate if the host is loose about extra imports. The bug surfaces as a runtime panic or a missing function error deep inside a callback, far from the actual source of the problem. With --allow-undefined gone, that same mistake becomes a link error pointing directly at the offending declaration.

There is also a tooling argument. WebAssembly’s import section is metadata that tools can inspect. Polyfill generators, bundlers, and runtime analyzers can read it and determine, ahead of time, whether a wasm module will run correctly in a given environment. A module that was built with --allow-undefined may have an incorrect or incomplete import section, which breaks that entire analysis pipeline. Making the import section accurate by default is worth the migration cost.

Migration In Practice

For most Rust wasm projects, the migration is straightforward. Audit your extern "C" blocks that reference host functions, add #[link(wasm_import_module = "...")] with the appropriate module name, and rebuild. If you hit link errors in dependencies you don’t control, open an issue upstream and use -C link-arg=--allow-undefined as a temporary workaround.

The Rust team has staged this change carefully rather than flipping a switch overnight, which is consistent with how the project generally handles potentially-breaking toolchain changes. The announcement provides enough lead time to adapt.

The change is not surprising given the trajectory of the wasm ecosystem. As WebAssembly moves from a browser compilation target toward a universal compute substrate used in edge runtimes, plugin systems, and server-side sandboxing, the correctness guarantees of every layer matter more. A linker that silently drops undefined symbols is an awkward foundation for that vision. Removing --allow-undefined is a small step, but it makes Rust’s WebAssembly output more honest about what it actually needs from the world around it.

Was this interesting?