Mehdi Akiki
Rust Failure Atlas / Upgrades and compatibility

RFA-709 · Case file with fixtures · Case 681 of 694 · Compiler evidence

unsafe(link_section) Still Fails Under deny(unsafe_code)

The unsafe(...) wrapper acknowledges an attribute's proof obligation; it does not override a crate policy denying unsafe code. Rust 1.98 applies that lint consistently to all unsafe attributes.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all targets accepting link_section
Profiles
dev, release

Direct answer

What this Rust failure means

Why it happens
The unsafe(...) wrapper acknowledges the attribute's obligation, while the unsafe_code lint is a separate project policy that Rust 1.98 applies consistently to unsafe attributes.
First discriminating check
Separate edition syntax from lint policy, identify why the custom section is necessary, and remove it or create a narrowly audited exception at the owning item.

Rust can reject an unsafe attribute for two different reasons which are easy to mix together.

This source uses the Rust 2024 wrapper correctly:

#![deny(unsafe_code)]

#[unsafe(link_section = ".rfa_registry")]
static REGISTRATION: [u8; 1] = [0];

There is no missing unsafe(...) syntax. Rust 1.98 still rejects it because #![deny(unsafe_code)] is a separate policy. The compiler now emits the lint consistently for all unsafe attributes.

The failure reduction records the diagnostic for link_section. The repair removes manual section placement and keeps an ordinary #[used] static under the deny policy. That repair is deliberately small; it proves the policy boundary, not that #[used] has every linker behavior of a custom section.

The wrapper and the lint answer different questions

#[unsafe(link_section = "...")] says: I recognize that choosing a linker section creates conditions the Rust compiler cannot verify for me.

#![deny(unsafe_code)] says: this crate does not accept operations or attributes classified as unsafe.

Acknowledging an obligation does not grant an exception to the policy. This is similar to an unsafe block inside a crate that denies unsafe code. The block may be syntactically explicit, but the lint can still reject it.

This matters during upgrades because one error can look like the earlier edition migration. Rust 2024 requires unsafe attributes such as no_mangle, export_name, and link_section to be written with the unsafe wrapper. Adding the wrapper fixes that syntax failure. It cannot fix a deliberate unsafe_code denial.

Manual section placement participates in a contract outside Rust's normal type system. A section name can affect permissions, initialization, retention, ordering, address ranges, loader behavior, and linker-script matching. The same name may mean something different across object formats and targets.

A registration item placed in the wrong section may simply disappear during dead stripping. More seriously, data may land in an executable or read-only region with properties that disagree with the Rust declaration. Start and stop symbols can produce invalid ranges if alignment or element layout is wrong. A bootloader, plugin loader, or embedded runtime can interpret bytes under a convention the Rust type does not express.

The attribute being syntactically attached to one static does not localize all consequences to that line. The proof includes the linker configuration and the code which consumes the section.

Three honest repairs

The first repair is removal. Sometimes a custom section survived from an old registry design even though normal constructors, an explicit table, or generated code now provides the same feature. Removing it reduces a platform-specific contract.

The second repair is architectural isolation. I can move the small platform boundary into a dedicated crate that permits a tightly reviewed unsafe attribute, while the main crate keeps deny(unsafe_code). This makes policy scope match responsibility. It also gives target-specific tests a clear home.

The third repair is a narrow lint exception when the project intentionally allows this case:

#![deny(unsafe_code)]

#[allow(unsafe_code)]
#[unsafe(link_section = ".my_registry")]
static ENTRY: Entry = /* reviewed value */;

Whether that local allow is accepted depends on the project's lint policy. forbid(unsafe_code) cannot be overridden in a child scope, and command-line lint caps or workspace tooling may impose stronger rules. I do not weaken a crate-wide policy without naming the owner and proof beside the exception.

What I document for an intentional exception

“The compiler requires unsafe” is not a safety comment. I record the actual conditions:

  • which targets and object formats recognize the section;
  • which linker script or loader consumes it;
  • the required alignment, element layout, and ordering;
  • why the item is retained under release linking and LTO;
  • whether the region is writable, executable, or initialized;
  • how section boundaries are discovered and validated;
  • what happens on an unsupported target.

I then inspect the linked artifact, not only the Rust metadata. Tools such as readelf, llvm-readobj, objdump, or target-specific equivalents can show section names, flags, addresses, and contents. A compile pass proves only that rustc accepted my declaration.

Why adding allow globally is a weak fix

Changing deny(unsafe_code) to allow(unsafe_code) makes the immediate build green, but it changes the review contract for the entire crate. Future unsafe blocks and attributes can arrive silently. That is much larger than the original requirement.

Likewise, adding #![allow(unsafe_code)] to generated code can hide new unsafe surfaces when a generator changes. I prefer an item-level exception, generated-output audit, or a separate boundary crate.

Suppressing the specific lint at the build command can also produce a local/CI split. If policy is intentional, it belongs in version-controlled configuration with an explanation.

A regression test must include the linker

For the lint itself, the Atlas reduction is enough: the unsafe attribute fails under deny, while an ordinary static compiles.

For a real custom section, I add a second level. I build every relevant profile, inspect the final section, verify its flags and alignment, and run the consumer. Debug success is weak because release dead stripping and LTO can change retention. Cross-target success is not implied by the host build.

The core principle is about engineering policy. Explicit unsafe syntax identifies where a proof is owed. A lint decides whether that kind of proof is allowed in this scope. Rust 1.98 makes these two layers more consistent, so the right response is to decide who owns the unsafe boundary—not to keep adding syntax until the error disappears.