rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

RFC: `#[export_visibility = ...]` attribute.

Open anforowicz opened this issue 5 months ago • 9 comments

This RFC proposes to add #[export_visibility = …] attribute, which seems like a reasonable way to address the following issues:

  • https://github.com/rust-lang/rust/issues/98449
  • https://github.com/rust-lang/rust/issues/73958

This RFC complements the -Zdefault-visibility=... command-line flag, which is tracked in https://github.com/rust-lang/rust/issues/131090

This PR replaces the Major Change Proposal (MCP) at https://github.com/rust-lang/compiler-team/issues/881 (/cc @bjorn3, @ChrisDenton, @chorman0773, @joshtriplett, @mati865, @workingjubilee, and @Urgau who have kindly provided feedback in the Zulip thread associated with that MCP)

/cc @tmandry from https://github.com/rust-lang/rust-project-goals/issues/253, because one area where this RFC seems needed is FFI tooling

Rendered

anforowicz avatar Jun 16 '25 19:06 anforowicz

For most use cases rather than specifying the exact symbol visibility (which may not even be supported by the object file format, like interposable on pe/coff or (with the default two-level namespaces) mach-o) I think having just a way to force SymbolExportLevel::Rust rather than the default SymbolExportLevel::C would be a better idea. This causes it to still be exported from rust dylibs (as necessary to avoid linker errors depending on the exactly when rustc decides to codegen functions), but prevents it from being exported from cdylibs. It doesn't work for staticlibs currently, but for those if you want to limit symbol visibility you have to specify your own version script during linking anyway to prevent exporting all rust mangled symbols too.

bjorn3 avatar Jun 16 '25 20:06 bjorn3

For most use cases rather than specifying the exact symbol visibility (which may not even be supported by the object file format, like interposable on pe/coff or (with the default two-level namespaces) mach-o) I think having just a way to force SymbolExportLevel::Rust rather than the default SymbolExportLevel::C would be a better idea. This causes it to still be exported from rust dylibs (as necessary to avoid linker errors depending on the exactly when rustc decides to codegen functions), but prevents it from being exported from cdylibs.

The fact that you are distinguishing between dylibs and cdylibs makes me think that you assume that linking is driven by rustc. If so, then this may not apply to Chromium, which uses an external linker.

It doesn't work for staticlibs currently, but for those if you want to limit symbol visibility you have to specify your own version script during linking anyway to prevent exporting all rust mangled symbols too.

That's not 100% correct - instead of using a version script, one may also use -Zdefault-visibility=hidden.

anforowicz avatar Jun 16 '25 21:06 anforowicz

The fact that you are distinguishing between dylibs and cdylibs makes me think that you assume that linking is driven by rustc. If so, then this may not apply to Chromium, which uses an external linker.

The Chromium case is effectively equivalent to using staticlibs, not to using rust dylibs/cdylibs.

That's not 100% correct - instead of using a version script, one may also use -Zdefault-visibility=hidden.

That doesn't apply to the standard library unless you go out of your way using unstable features to recompile the standard library.

bjorn3 avatar Jun 16 '25 21:06 bjorn3

The fact that you are distinguishing between dylibs and cdylibs makes me think that you assume that linking is driven by rustc. If so, then this may not apply to Chromium, which uses an external linker.

The Chromium case is effectively equivalent to using staticlibs, not to using rust dylibs/cdylibs.

Ack / agreed.

That's not 100% correct - instead of using a version script, one may also use -Zdefault-visibility=hidden.

That doesn't apply to the standard library unless you go out of your way using unstable features to recompile the standard library.

Thank you for bringing up this point. This probably should be explicitly addressed by the RFC (*), but I am not sure if I agree with your conclusions so far. This is because:

  • Chromium does in fact compile the standard library within Chromium's build system (e.g. see build/rust/std/rules/BUILD.gn auto-generated from standard library's Cargo.toml files)
  • But using a pre-built standard library doesn't necessarily make #[export_visibility = ...] less useful, because there may be other actions that can be taken to change the behavior of the standard library. For example, the RFC discusses changing the behavior of #[export_name = ...] and/or #[no_mangle] (in a future Rust language edition) so that in the future these attributes imply #[export_visibility = "inherit"] rather than #[export_visibility = "interposable"]. So maybe a similar change can/should be applied to #[rustc_std_internal_symbol]? And while users of #[no_mangle] and/or #[export_name = ...] may actually want the public-export behavior of these attributes, I think this is not the case for standard library symbols (so maybe the change to inherit behavior could even be done within the current language edition; or maybe trigerred by -Zdefault-visibility although this then would get quite close to the -Zdefault-visibility-for-c-exports=... alternative from the RFC).

(*) I am not sure what the right process is here. Should I add commits to the RFC as we keep discussing here? Should I first give people an opportunity to review the first draft?

anforowicz avatar Jun 16 '25 21:06 anforowicz

I think this is a good opportunity to expand the design space (and documentation) of "various levels of exportendess" a bit, even if the resulting proposal for export_visibility specifically stays more or less the same.

There are multiple attributes that targets this similar space (export_visibility, linkage, used, rustc_std_internal_symbol), so it would be good to somehow target them together properly.

What I'd like to see is a table of "levels of exportedness" combined with the kinds of end artifacts, and how we can users can express all those levels with the attributes listed above.

  • a symbol only visible inside an object file
  • a symbol visible outside of an object file but not outside of a cdylib/dylib/executable
  • a symbol visible outside of a cdylib
  • a symbol visible outside of a rust dylib
  • a symbol visible outside of an executable
  • a symbol visible outside of a cdylib/dylib/executable but not some other crate type
  • a symbol visible outside of an object file inside a rlib/staticlib, but not outside of it
  • a symbol visible outside of an X but only for LTO, after that it is only visible outside of Y (I think I've seen some issue about this in the tracker)
  • where is the information about the exportedness level stored? in which case it can be stored in the target's object format (e.g. ELF, COFF or even archive metadata) and in which cases it needs to be stored in rmeta and require rustc for interpreting it (e.g. rustc must be used for linking).
  • if a symbol is visible outside of X, does it mean that it is used in some sense? who can optimize that used symbol away in each case?
  • if a symbol is used(X), does it also mean that it is visible outside of Y?
  • which of the case combinations above make sense?

In particular, one of my requirements is that #[rustc_std_internal_symbol] should be expressible as an alias to several more fine-grained and single-purpose attributes available to users. IIRC, it had some LTO-related visibility requirement in particular. Also, all symbols hard-coded in the compiler by name (there were such symbols in the past, not sure about now, some were migrated to rustc_std_internal_symbol) should be expressible by the same fine-grained attributes as well.

petrochenkov avatar Jun 17 '25 15:06 petrochenkov

a symbol only visible inside an object file

This is something only rustc must be allowed to do (other than for symbols defined in inline asm called from within the same inline asm block). Only rustc knows if all callers will end up in the same object file as the definition and it doesn't provide any guarantees around when this happens. So exposing this to the user is a stability hazard.

where is the information about the exportedness level stored? in which case it can be stored in the target's object format (e.g. ELF, COFF or even archive metadata) and in which cases it needs to be stored in rmeta and require rustc for interpreting it (e.g. rustc must be used for linking).

For regular functions and #[rustc_std_internal_symbol] we have to store it in the rmeta and use a version script as at compile time we don't yet know if the object file ends up in a rust dylib or cdylib.

a symbol visible outside of an object file inside a rlib/staticlib, but not outside of it

For rlib this doesn't make sense. There is no way to make rlibs a symbol export boundary without introducing an expensive link/object file rewrite step for each individual rlib. For staticlib it would be nice to have a symbol export boundary, but unfortunately we don't have one right now even for SymbolExportLevel::Rust (which really shouldn't be exported from staticlibs and for which we already support not exporting them from cdylibs) except I believe when we do (fat?) LTO as in that case all object files in the staticlib get optimized together allowing them to be internalized in the output object.

a symbol visible outside of a cdylib

This makes sense to me. See the end of my comment.

a symbol visible outside of a rust dylib

This has to always be the case if it is visible outside of the object file. The very point of rust dylibs is that rust code in a separate DSO can call any public function, which thanks to cross-crate inlining can call effectively every function that rustc wouldn't make private to the current object file. And again, rustc doesn't provide any guarantees when this happens, so allowing you to not export symbols from a rust dylib is a stability hazard.

if a symbol is visible outside of X, does it mean that it is used in some sense? who can optimize that used symbol away in each case?

Yes.

if a symbol is used(X), does it also mean that it is visible outside of Y?

No

Also, all symbols hard-coded in the compiler by name (there were such symbols in the past, not sure about now, some were migrated to rustc_std_internal_symbol) should be expressible by the same fine-grained attributes as well.

rust_eh_personality should be the only remaining symbol with a hard coded name once https://github.com/rust-lang/rust/pull/141061 lands (which removes the unmangled __rust_no_alloc_shim_is_unstable in favor of a mangled __rust_no_alloc_shim_is_unstable_v2). We unfortunately can't mangle it's name as LLVM hard codes it.

IIRC, it had some LTO-related visibility requirement in particular.

Not really aside from the visibility information we already tell the linker (export from rust dylib, don't export from cdylib).

Currently rustc internally works with three different symbol export levels:

  • Not exported from the object file. This is done using internal symbol linkage.
  • SymbolExportLevel::Rust. This exports from a rust dylib, but not a cdylib. This is for #[rustc_std_internal_symbol] and regular rust functions that are not #[no_mangle] that aren't made private to the object file either
  • SymbolExportLevel::C. This exports from all crate types (for bin only when -Zexecutable-export-symbols is passed). This is enabled using #[no_mangle].

It makes sense to me to allow SymbolExportLevel::Rust for #[no_mangle] symbols for C/C++ code that ends up getting linked into the same cdylib.

bjorn3 avatar Jun 17 '25 15:06 bjorn3

It makes sense to me to allow SymbolExportLevel::Rust for #[no_mangle] symbols for C/C++ code that ends up getting linked into the same cdylib.

I think this probably should be captured somehow as one of the alternatives in the RFC. Is there a specific syntax that you have in mind here? I guess one option would be to have a #[symbol_export_level = "rust"] or maybe #[no_c_level_symbol_export] (or #[no_cdylib_symbol_export]?), although maybe the names could be improved somehow.

anforowicz avatar Jun 17 '25 18:06 anforowicz

I think this probably should be captured somehow as one of the alternatives in the RFC.

:+1:

#[symbol_export_level = "rust"] or maybe #[no_c_level_symbol_export]

I don't think this is a good name as it is still meant to be usable from C, just not outside of the linked DSO.

#[no_cdylib_symbol_export]

This would be an option, although ideally if we manage to stop exporting all symbols from staticlibs, I would like the same attribute to be usable to prevent export from both cdylib and staticlib, so it should probably not mention cdylib in the name. I don't have suggestions for a better name though.

bjorn3 avatar Jun 17 '25 18:06 bjorn3

Frankly, if we have #[no_cdylib_symbol_export], I'd like the inverse for imports at least, so that it's possible to export symbols defined in C (or another language) from a cdylib.

chorman0773 avatar Jun 17 '25 18:06 chorman0773

In Rust 1.87 and before on GNU/Linux, this code:

#![feature(rustc_attrs)]


#[rustc_std_internal_symbol]
#[no_mangle]
pub extern "C" fn blah(i: u32) -> u32 {
	i+1
}

produced an object with the blah symbol marked with internal linkage (t in nm output) This is required when linking a Rust shared library with C dependencies which call back to the Rust code, when the blah function is an internal glue function that should not be exported from the library.

What this issue is not applicable to:

  • linking most executables as opposed to DLLs (executables have exports removed by default during linking)
  • linking Rust code in a staticlib into a C library (we can use the likes of -Wl,--exclude-libs to hide any offending exports)
  • linking anything in pure Rust (there are no FFI requirements, any instances of #[no_mangle] can be removed from code)

Omitting the #[rustc_std_internal_symbol] produces a correctly-named symbol, but with incorrect external linkage (T in nm output). Such symbol can be called from outside the DLL, which we don't want to.

In C++, mangling (controlled by extern "C") and linkage (controlled by the visibility attribute) are completely orthogonal concepts. One should not control the other.

The analog to what #[rustc_std_internal_symbol] did in C/C++ is __attribute__((visibility("hidden"))) and we are trying to reproduce its effect on the ELF object.

This errors now in 1.88, the only way i'm aware of being injecting an assembly declaration core::arch::global_asm!(".hidden blah");. This obviously does not work when the binary is compiled with LTO.

brjsp avatar Jul 04 '25 18:07 brjsp

@bjorn3 - thank you for proposing a narrower fix, focusing on setting SymbolExportLevel::Rust for the exported symbols. I have:

  1. Prototyped this approach: https://github.com/anforowicz/rust/commit/9dd4d3f6b2beecb85ff4220502a8c7f61edca839
  2. Verified that this also addresses https://crbug.com/418073233: patchset 3 here: https://crrev.com/c/6580611/3
  3. Edited the RFC to list this approach as an alternative solution

From my perspective both #[export_visibility = ...] and #[rust_symbol_export_level] work equally well for addressing my problems. OTOH it seems that #[rust_symbol_export_level] may avoid some open questions and IIUC also avoids undesirable dylib interactions. So that probably makes the new approach preferable over #[export_visibility = ...]. I am not sure what the next steps are:

  • Continue discussing whether #[export_visibility = ...] may be more desirable than #[rust_symbol_export_level] for some other folks / other requirements / other scenarios?
  • Continue brainstorming other names for the #[rust_symbol_export_level] attribute? So far we had:
    • #[rust_symbol_export_level] - I've used this in the prototype because it directly maps to the compiler code... :-/ If the name is good enough for the compiler code, then maybe it is okay in a user-facing attribute?
    • no_cdylib_symbol_export
    • #[symbol_export_level = "rust"]
  • Later (not sure when?) consider switching to using #[rust_symbol_export_level] as the main approach. Not sure if I should open a new PR/RFC for this (preserving earlier commits I guess)? Or just edit the current one?

anforowicz avatar Jul 31 '25 00:07 anforowicz

I think the current RFC does a good job of creating a conceptual framework for symbol visibility that can abstract over platform differences, and the remaining issues seem like they can be addressed. Perhaps the issue with dylibs can be solved by linting on calls to non-inlined, hidden-linkage functions from inlined or generic functions.

My feeling is that having two export levels, "Rust" and "C", isn't quite the right abstraction – or if it is, we should be able to spell it as extern "Rust" and extern "C". There are more fine grained distinctions that users want to capture when it comes to symbol visibility, and they don't map directly onto Rust's existing concepts.

I love the idea @petrochenkov raised in https://github.com/rust-lang/rfcs/pull/3834#issuecomment-2980816227 of having a table of all the things you might want to accomplish and how you would spell them. I wouldn't put the onus on this RFC to completely fill out that table, but it would be helpful to know which gaps exist and which are getting filled.

It's also important to me that the users who really know what they're doing when it comes to symbol visibility should be able to exercise direct control. While those users are a small percentage of Rust users, I do see this as a significant benefit to them and to unlocking Rust usage in more arcane contexts. That control can be mediated by a table that maps from "how it's spelled in C compilers and linker scripts" to "how to spell it in Rust"; it doesn't require us to adopt the existing models or terminology wholesale.

There are places we might reasonably want to place limits on this (as brought up by @bjorn3 above, setting visibility to an individual codegen unit is almost certainly a bad idea). Otherwise I think we should have some path that favors flexibility and transparency, and avoid forcing outside experts who come to Rust to guess at complex logic the compiler might be doing and why.

tmandry avatar Sep 11 '25 19:09 tmandry

@rfcbot fcp merge

tmandry avatar Sep 30 '25 00:09 tmandry

Team member @tmandry has proposed to merge this. The next step is review by the rest of the tagged team members:

  • [x] @joshtriplett
  • [ ] @nikomatsakis
  • [ ] @scottmcm
  • [x] @tmandry
  • [ ] @traviscross

Concerns:

  • ~~due diligence on tier 1 platforms~~ resolved by https://github.com/rust-lang/rfcs/pull/3834#issuecomment-3407641301
  • ~~specify how inlining interacts with hidden symbols~~ resolved by https://github.com/rust-lang/rfcs/pull/3834#issuecomment-3407641301
  • ~~UB potential of interposable~~ resolved by https://github.com/rust-lang/rfcs/pull/3834#issuecomment-3407641301

Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!

cc @rust-lang/lang-advisors: FCP proposed for lang, please feel free to register concerns. See this document for info about what commands tagged team members can give me.

rust-rfcbot avatar Sep 30 '25 00:09 rust-rfcbot

Nominating for lang team feedback on the approach of this RFC. Assuming we want to control symbol visibilities, the main lang question is how to surface those visibilities.

This RFC proposes #[export_visibility = "X"], where X is one of:

  • interposable
  • protected
  • hidden
  • inherited

Explanations are in this RFC section.

The main alternative proposed is to create an attribute that tweaks the current compiler behavior, like #[rust_symbol_export_level] to make functions marked as #[no_mangle] otherwise behave like Rust functions in terms of their export level. This also solves the author's use case.

As I argue in https://github.com/rust-lang/rfcs/pull/3834#issuecomment-3282373591, I think it would be clearer to surface a more direct mapping to the symbol visibility concepts that appear in other languages like C/C++, in linker arguments, and in binary formats. That's what this RFC does.

@rustbot label I-lang-nominated

tmandry avatar Sep 30 '25 00:09 tmandry

Is this new attribute unsafe or not? From the caveats describes in the RFC, it sounds like it has to be unsafe.

RalfJung avatar Sep 30 '25 07:09 RalfJung

@RalfJung Why would it be unsafe? Visibility changes no behavior of the compiled program, it only has meaning to the linker. (Disregarding LTO)

brjsp avatar Sep 30 '25 09:09 brjsp

Apparently it can break dylibs as well. Also, changing which function the linker picks up for which call can change the behavior of the compiled program, can't it?

RalfJung avatar Sep 30 '25 09:09 RalfJung

It cannot make anything unsafe that was safe before. If a certain binary is a valid implementation of the default-visibility program, the same binary but with symbols artificially removed is a valid implementation of the hidden-visibility program.

brjsp avatar Sep 30 '25 10:09 brjsp

Modifying your binary (I assume that's what you mean by "artificially removed") is definitely an unsafe operation so I don't see how that's an argument for the attribute being safe.

RalfJung avatar Sep 30 '25 10:09 RalfJung

My point is that visibility only affects behavior of outside code which is beyond Rust's business. By your argument, #[no_mangle] alone should already be unsafe since linking with outside code is unsafe.

brjsp avatar Sep 30 '25 10:09 brjsp

By your argument, #[no_mangle] alone should already be unsafe since linking with outside code is unsafe.

#[no_mangle] is unsafe: https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-attributes.html

programmerjake avatar Sep 30 '25 10:09 programmerjake

Then it's no problem since Rust mangled symbols have hidden visibility anyway!

brjsp avatar Sep 30 '25 10:09 brjsp

My point is that visibility only affects behavior of outside code which is beyond Rust's business.

It also affects rust code that calls the function if it is included in a rust dylib.

bjorn3 avatar Sep 30 '25 11:09 bjorn3

The target audience for this attribute is building cdylibs, not rust dylibs.

brjsp avatar Sep 30 '25 11:09 brjsp

Do you want usage of the #[export_visibility] attribute anywhere in the crate graph to be a hard error when building a rust dylib? If not, we have to take the behavior of #[export_visibility] around rust dylibs into account.

bjorn3 avatar Sep 30 '25 11:09 bjorn3

By your argument, #[no_mangle] alone should already be unsafe since linking with outside code is unsafe.

#[no_mangle] is unsafe

Then it's no problem since Rust mangled symbols have hidden visibility anyway!

...?!?

So first you implied that no_mangle should be safe but then when you learned that it isn't you take that as supporting your argument? That doesn't seem coherent.

(Btw, the reason it is unsafe is that one can use it to overwrite well-known functions like malloc or write and then subsequently cause UB.)

My point is that visibility only affects behavior of outside code which is beyond Rust's business.

That's not true when the Rust compiler is doing the linking. So we have to ensure that all those cases still behave correctly.

The target audience for this attribute is building cdylibs, not rust dylibs.

How's that relevant for the safety of the attribute?

If there's any way for this to cause UB in Rust code, it must be unsafe. It doesn't matter whether that is using the attribute as intended or not. The bar for a safe attribute/function in Rust generally is that it must be impossible for this to make the program go wrong, even when deliberately misusing the language.

To be clear, I don't know whether it should be unsafe or not. I am asking you and the other domain experts here.

RalfJung avatar Sep 30 '25 11:09 RalfJung

My take is:

  1. It must be usable anywhere #[no_mangle] is usable. That includes being backported to old language editions which had #[no_mangle] but no unsafe attributes.
  2. It makes zero sense to be used without #[no_mangle] as no unmangled functions are exported in the discussed use cases.

brjsp avatar Sep 30 '25 14:09 brjsp

That includes being backported to old language editions which had #[no_mangle] but no unsafe attributes.

#[unsafe(naked)] uses unsafe on all editions. export_visibility can do the same. #[no_mangle] doesn't need unsafe on older editions only for back compat reasons.

It makes zero sense to be used without #[no_mangle] as no unmangled functions are exported in the discussed use cases.

This I agree with.

bjorn3 avatar Sep 30 '25 14:09 bjorn3

Is this new attribute unsafe or not? From the caveats describes in the RFC, it sounds like it has to be unsafe.

RFC author here. I think the new attribute does not need to marked unsafe:

  • The risk of undefined behavior caused by naming collisions (two different definitions having the same linking name) comes solely from #[no_mangle] and #[export_name = ...] attributes. Presence or absence of #[export_visibility = ...] doesn't affect this risk.
  • IIUC hiding symbols from dylib may result in linking failures (symbol X not found). This does not seem like a risk of undefined behavior. I think this kind of risk (no risk of UB) doesn't need an unsafe annotation. In other words - I think this risk is quite similar to the risk of forgetting to write pub mod instead of mod (and we don't require writing unsafe mod).

anforowicz avatar Sep 30 '25 14:09 anforowicz