RFA-598 · Case file with fixtures · Case 570 of 694 · Compiler evidence
Rust Exported Symbol Names Cannot Contain NUL Bytes
An exported symbol is a linker-level ABI identity, not arbitrary text. Use a valid stable name and verify the complete foreign signature and artifact.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- object formats and linkers supported by Rust
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Runtime C-string termination was confused with a source-level global symbol name that linkers must store and resolve without embedded nulls.
- First discriminating check
- Use the exact valid ABI name without a terminator, then verify uniqueness, calling convention, layouts, and the final exported artifact.
An exported function name is written into an object file for linkers and foreign callers. It is not arbitrary Rust string data. The failing fixture embeds \0 in export_name, and rustc reports E0648.
NUL is not an escaping trick for symbol names
Many C-facing APIs use nul-terminated strings at runtime, but the terminator is not part of the logical name. Adding it to an attribute creates an embedded null character rather than helping the linker.
The official E0648 page instructs removing null characters. Toolchains and object formats need a representable symbol identity; accepting a string that tools truncate differently would create collisions and lookup failures.
I use the exact name specified by the foreign header, plugin protocol, or host runtime. I do not add C string termination to source-level symbol attributes.
The repair creates one stable linker identity
The repaired fixture exports service_entry. It also declares extern "C" so the callable ABI is explicit. The function is invoked normally only to keep the fixture active.
In edition 2024, export_name is an unsafe attribute written with #[unsafe(export_name = ...)]. The Edition Guide explains that these attributes carry safety obligations, including global symbol uniqueness. Syntax acknowledgement does not discharge those obligations.
Two crates exporting the same symbol can cause link failures or, in some environments, one definition unexpectedly winning. I prefix names according to the real ABI and review the complete final link, not only each crate.
Symbol name is only one part of FFI compatibility
A foreign caller also relies on calling convention, parameter and return layouts, unwind behaviour, and ownership. Exporting a valid name with Rust's default ABI when the caller expects C remains wrong.
I use FFI-safe types or opaque pointers, document who allocates and releases resources, and define error representation. Panics must not cross an ABI boundary that does not permit unwinding. Versioning and thread-safety are part of the contract too.
The Reference documents the export_name attribute. I treat it as low-level linkage control rather than a replacement for a generated header or reviewed binding layer.
Verify the artifact, not only the source
Optimisation, dead-code elimination, visibility settings, and target formats can affect exported artifacts. I inspect the built library with platform symbol tools and run a foreign-language smoke test that loads and calls the function.
On systems with leading underscores, symbol versioning, or dynamic library export lists, the final visible identity may involve toolchain rules beyond the attribute. CI should cover each supported target and release link mode.
If a host discovers plugins by exact symbol, the smoke test should use the same lookup mechanism as production. Calling the Rust function directly cannot prove export visibility or ABI agreement.
Generated names need validation and collision control
Macros may derive symbol names from user input, module paths, or protocol versions. I validate allowed characters, make collision rules deterministic, and include ABI versioning intentionally. NUL rejection is one minimum constraint, not the full naming policy.
Names should remain stable across Rust refactors because foreign code does not understand module moves. A central binding specification is safer than repeating literals across functions.
Changing an exported name is an ABI-breaking change unless the old symbol remains as a compatible forwarding entry. I coordinate consumers and test both transition paths.
Symbol stability and implementation stability are separate
Keeping one export name does not mean its Rust implementation must stay in one module. I can move internal code and retain a small exported wrapper whose ABI remains stable. This is another reason to avoid putting complex logic directly in the foreign entrypoint.
The wrapper validates raw arguments, converts them to safe domain values, calls ordinary Rust code, and translates the result back to the documented foreign representation. That structure gives unit tests access to the logic without calling across FFI, while integration tests verify the wrapper. If an ABI version needs incompatible arguments, I export a new versioned symbol rather than silently changing the function behind the old name. A valid symbol spelling is useful only when its meaning remains equally disciplined.
My E0648 checklist
- Does the requested name contain an actual or escaped NUL character?
- What authoritative foreign contract defines the exact symbol spelling?
- Is the unsafe attribute syntax correct for the project's edition?
- Is the name globally unique in the final linked artifact?
- Do ABI, layouts, ownership, errors, and unwind policy match the caller?
- Does a symbol-inspection step see the expected release export?
- Does a real foreign caller load and invoke it on every target?
- Are generated names validated, collision-resistant, and versioned deliberately?
The core principle is that an exported name is a global binary-interface identity. E0648 rejects text that cannot serve reliably as that identity. I fix the spelling, then verify the much broader ABI promise in the produced artifact.