Private struct can be exposed into public scope

While experimenting, I managed to expose a private struct outside of its scope, you can't directly reference it, but the compiler doesn't seem to prevent you from using a value; you cannot, however, write the type.

Is this intended behavior?

#![feature(type_name_of_val)]

mod inner {
    mod internal {
        #[derive(Debug, Clone, PartialEq)]
        pub struct MyInternalStruct {
            pub field: usize,
        }
    }

    use internal::*;

    #[derive(Debug, Clone)]
    pub struct MyPublicStruct {
        pub internal: MyInternalStruct, // shouldn't this be a compile error, as it reveals a private struct in public context?
    }
    
    pub fn get_internal(n: usize) -> MyInternalStruct { // same with this
        MyInternalStruct { field: n } // and this
    }
}

use inner::*;

fn main() {
    dbg!(get_internal(1)); // how are we able to get a value of a private type?
                           // get_internal(1) = MyInternalStruct { field: 1 }
    
    let bound = get_internal(2); // we can bind this value
    dbg!(&bound); // and use it
                  // &bound = MyInternalStruct { field: 2 }
    let public = MyPublicStruct { internal: bound }; // and it is the same exact type
    
    dbg!(std::any::type_name_of_val(&public)); // → std::any::type_name_of_val(&public) = "playground::inner::MyPublicStruct"
    
    fn test<T>(what: T) -> T { // generics function as well
        let huh = what;
        huh
    }
    
    let why = get_internal(3);
    let how = test(why.clone()); // methods work
    assert_eq!(why, how); // succeeds
    
    // Compile errors //
    /*
    let cannot_write_type: inner::MyInternalStruct = get_internal(3); // yet we cannot write it's type?
                                                                      // error: struct import `MyInternalStruct` is private
    
    let direct = inner::internal::MyInternalStruct { field: 4 }; // writing it directly doesn't work
                                                                 // error: module `internal` is private
    */
}

(Playground)

This is allowed, since MyInternalStruct is still declared pub, its name is just inaccessible to code outside inner. This pattern is often used to create sealed traits, traits which are public but cannot be implemented by outside code, by declaring an inaccessible pub trait as a supertrait of an accessible trait. If you declare MyInternalStruct as pub(super), you'll see that the compiler emits the expected visibility errors.

It's still odd that you can get and use a value of a type you don't have access to. I understand the sealed trait pattern, which makes sense as you're explicitly restricting trait impls by requiring an impl of a trait you don't have access to, but MyInternalStruct is in a module that is private and is never publicly exported, it feels weird to be able to directly work with and inspect types you can't even name.

rustc does provide the off-by-default lint #[warn(unreachable_pub)], which exists to help prevent accidental occurrences of this, e.g.

warning: unreachable `pub` item
  --> src/main.rs:15:5
   |
15 |     pub struct MyPublicStruct {
   |     ---^^^^^^^^^^^^^^^^^^^^^^
   |     |
   |     help: consider restricting its visibility: `pub(crate)`
   |
   = help: or consider exporting it for use by other crates
note: the lint level is defined here
  --> src/main.rs:2:9
   |
2  | #![warn(unreachable_pub)]
   |         ^^^^^^^^^^^^^^^

Unfortunately, it's not perfectly accurate; it's possible that a type is externally reachable but still warns; this commonly happens with multiple levels of (especially glob) reexports, IIRC. Additionally, it's a lot of noise; many developers still prefer just using the pub-and-reachability approach over using the longer pub(crate).

Unnamable types are certainly weird, but it's worth noting that you can actually use type alias impl trait (unstable) to give an opaque name to the type, if you can get a value of it.

Your example doesn't contain a private struct. It contains a public struct, with some weird visibility scope. The privacy of items in Rust is determined exclusively by the visibility modifier on the item. struct Foo is always private, pub struct Foo is always public, pub(in path) struct Foo has visibility restricted to path.

The visibility modifier provides a hard upper bound on the scope of the item. A private item can never be accessed outside of its declaring module, it's a compile error. On the other hand, a public item is potentially visible to the whole world. Whether it is actually visible depends on the subtle details of your crate's structure. Your example shows that an item with no direct access path may still be accessible in roundabout ways. But that doesn't violate privacy guarantees in any way.

I always found it weird to use private modules for sealed traits. Hopefully the language will receive an official "sealed trait" feature soon. I don't know about the current progress on that.

However, this is an interesting point:

I'm still a bit confused though. We can't leak types that are explicitly declared as private. Is there a good reason to be able to leak unreachable types other than providing backward compatibility for the sealed trait pattern? Maybe there is?

Is this just am unfortunate implementation? Or is this problem generally difficult to solve properly?

Interesting! I was under the impression that once one level of mod marks it private, it becomes completely inaccessible outside that level, thank you all for the help with understanding!

FWIW, it's also perfectly fine to return a private type for regular -> impl Trait.

This can't be the case, because you can pub use the type to make it accessible via reexport, e.g.

mod privacy {
    pub struct S;
}
pub use privacy::S;

If this weren't the case, then all types would have to be defined in the module at which they're publicly visible.

Other languages do define privacy in this manner; in fact, I'm fairly comfortable saying most semi-mainstream languages (that have any namespacing to speak of) function this way.

There's a major difference between how most languages with "definition namespace is public export" versus Rust, though.

I'll pick on Java, since I know its semantics well. In Java, /com/cad97/utils/A.java and /com/cad97/utils/B.java define two types in the same namespace, com.cad97.utils.A and com.cad97.utils.B.

In Rust, /utils/a.rs and /utils/b.rs define two different modules; if they also define the same types, you get crate::utils::a::A and crate::utils::b::B. To get the same namespacing as with Java, you make the modules private and in /utils/mod.rs (or /utils.rs) include pub use self::{a::A, b::B} alongside the mod declarations. The type is still crate::utils::a::A, but its publicly accessible path (assuming the utils mod is public) is just crate::utils::A, and you've successfully exported types defined in separate files under a shared namespace.

I'm not as familiar with Swift, but IIRC it's like Java in that all /utils/*.swift files define items in a shared utils namespace, but it doesn't have the file name == item name rule that Java does.

In other words, in most other languages, "private namespaces" aren't a thing, and pub Item means the item is public at the definition location. In Rust, pub Item still means the item is public; it's just that the containing namespace is separately allowed to be non-public, making the public type unnameable unless it's publically reexported.

As a historical note, before 2018 this was the only option for privacy control in Rust. pub(crate) (more generally, pub(in path)) was introduced such that it was possible to define types with a hard upper bound to visibility, visible outside their defining module but not reexportable beyond the specified boundary. This also makes it possible to define a crate-private type in a public module. (This isn't achievable in all languages without module privacy; if available it's typically an internal privacy modifier. Or in typical C++ fashion, just a request in documentation not to use types defined in this details namespace.)

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.