Skip to content

Unsoundness due to #[pin_v2] incorrectly handles packed structs. #157011

Closed

Description

@theemathas

When the destructor of a repr(packed) struct runs, if it has a field which is unaligned and has a destructor, that field will be moved to an aligned place before the destructor of that field is ran. (See also #143411, taiki-e/pin-project-lite#89, taiki-e/pin-project#34.)

The #[pin_v2] attribute does not take this into account. This allows a Drop impl of a type to access a value of that type via &mut, even after pin-projection was used to access a value of that same type via Pin<&mut>.

The below code demonstrates this unsoundness. The two addresses it prints are different, showing that the invariant of Pin was violated.

#![feature(pin_ergonomics)]
#![expect(incomplete_features)]

use std::{
    marker::PhantomPinned,
    pin::{Pin, pin},
};

#[pin_v2]
#[repr(C, packed(4))]
struct One<T>(T);

#[pin_v2]
#[repr(C, align(65536))]
struct Two(Thing);

struct Thing(#[expect(dead_code)] i32, PhantomPinned);

fn main() {
    let pinned_one: Pin<&mut One<Two>> = pin!(One(Two(Thing(0, PhantomPinned))));
    let &pin mut One(Two(ref pin mut pinned_thing)) = pinned_one;
    access(pinned_thing);
}

fn access(x: Pin<&mut Thing>) {
    println!("Pinned access at {x:p}")
}

impl Drop for Thing {
    fn drop(&mut self) {
        println!("Dropped at {self:p}");
    }
}

Here's an explanation of the various parts this code:

  • The One struct is packed to an alignment of 4 bytes. This causes the Thing field to also be aligned to 4 bytes, which allows projecting pinned_one to pinned_thing without causing a compile error due to an unaligned reference to Thing.
  • The Two struct is aligned to more than 4 bytes. This means that, when the destructor of One is ran, it notices that its field has an alignment of 65536. Therefore, this Two field is moved to an aligned place before Two's destructor is ran.
  • The One struct needs to be generic, to work around a compile error that prevents me from directly putting a repr(align) type inside a repr(packed) type.

Meta

Reproducible on the playground with version 1.98.0-nightly (2026-05-26 d1fc603d1788cc3c0eeb)

Activity

  1. added
    A-destructorsArea: Destructors (`Drop`, …)
    T-langRelevant to the language team
    T-compilerRelevant to the compiler team, which will review and decide on the PR/issue.
    I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness
    on May 27, 2026
  2. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on May 27, 2026
  3. added
    C-bugCategory: This is a bug.
    A-reprArea: the `#[repr(stuff)]` attribute
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on May 27, 2026
  4. Dnreikronos commented on Jun 3, 2026

    @Dnreikronos
    Contributor

    When the destructor of a repr(packed) struct runs, if it has a field which is unaligned and has a destructor, that field will be moved to an aligned place before the destructor of that field is ran. (See also #143411, taiki-e/pin-project-lite#89, taiki-e/pin-project#34.)

    The #[pin_v2] attribute does not take this into account. This allows a Drop impl of a type to access a value of that type via &mut, even after pin-projection was used to access a value of that same type via Pin<&mut>.

    The below code demonstrates this unsoundness. The two addresses it prints are different, showing that the invariant of Pin was violated.

    #![feature(pin_ergonomics)]
    #![expect(incomplete_features)]

    use std::{
    marker::PhantomPinned,
    pin::{Pin, pin},
    };

    #[pin_v2]
    #[repr(C, packed(4))]
    struct One(T);

    #[pin_v2]
    #[repr(C, align(65536))]
    struct Two(Thing);

    struct Thing(#[expect(dead_code)] i32, PhantomPinned);

    fn main() {
    let pinned_one: Pin<&mut One> = pin!(One(Two(Thing(0, PhantomPinned))));
    let &pin mut One(Two(ref pin mut pinned_thing)) = pinned_one;
    access(pinned_thing);
    }

    fn access(x: Pin<&mut Thing>) {
    println!("Pinned access at {x:p}")
    }

    impl Drop for Thing {
    fn drop(&mut self) {
    println!("Dropped at {self:p}");
    }
    }
    Here's an explanation of the various parts this code:

    • The One struct is packed to an alignment of 4 bytes. This causes the Thing field to also be aligned to 4 bytes, which allows projecting pinned_one to pinned_thing without causing a compile error due to an unaligned reference to Thing.
    • The Two struct is aligned to more than 4 bytes. This means that, when the destructor of One is ran, it notices that its field has an alignment of 65536. Therefore, this Two field is moved to an aligned place before Two's destructor is ran.
    • The One struct needs to be generic, to work around a compile error that prevents me from directly putting a repr(align) type inside a repr(packed) type.

    Meta

    Reproducible on the playground with version 1.98.0-nightly (2026-05-26 d1fc603d1788cc3c0eeb)

    I took a look into and reached this:

    The simplest fix here is probably to just ban #[pin_v2] on #[repr(packed)], which is what pin-project and pin-project-lite do. But I think that's broader than the actual hole, because a lot of this is already blocked today:

    So we can't drop a concrete repr(align) field in there, and you can't &pin mut an under-aligned leaf either.

    But the real problem is the move-on-drop. When a packed(N) struct drops, any direct field f with align(f) > N gets moved to an aligned spot first, and that move takes whatever is inside f with it, including a deeper leaf that is fine on its own and was handed out as Pin<&mut leaf>. That's exactly what the repro does: Thing (align 4) is fine under packed(4), so unaligned_references doesn't complain, but the field that moves is its parent Two (align 65536), and we never take a reference to Two, so nothing catches it.

    So there are really two cases the current checks miss. The first is a generic field, like One<T>. We don't know align(T) at definition time, and it can go over N once it's monomorphized, which is the repro. The second is plain natural over-alignment, no generics needed: a packed(4) field whose natural alignment is 8 (it has a u64 in it), with a pinned leaf at a 4-aligned offset. E0588 only checks repr(align), not natural alignment, so this one slips through.

    The generic one is the hard part, and honestly I don't think it can be both sound and permissive. #[repr(packed(N))] #[pin_v2] struct One<T>(T) is unsound the moment T has align > N, and there's no way to write "T's alignment is at most N" as a bound. So we have to restrict generic packed pin_v2 fields somehow no matter what.

    That said, instead of banning the whole thing, what if we did a per-field check at the struct definition? Reject a #[pin_v2] packed(N) struct only when a field can have align > N: a generic field we can't prove, so reject it; a concrete field we just work out its alignment and compare. The rule is basically "a pin_v2 type has to promise none of its pinned fields ever get moved by drop glue", which under packing just means align(field) <= pack at every packed level. The good thing is the safe case still compiles (a packed(N) pin_v2 struct where every field is <= N), and both cases above are closed.

    We could go further and check at the projection site instead, which would let One<T> stay defined and only reject the W-aligned uses. But that needs layout info right at the projection, and then you run into typeck running before layout, plus the post-mono cases, which feels like a lot of extra work for an experimental feature.

    I'm leaning towards the per-field check, since the plain ban throws away cases that are actually fine. But I don't feel strongly about it, and the ban is obviously less risky to implement. I can put up a PR for whichever you'd rather have

    What you think about it, @theemathas?

  5. theemathas commented on Jun 3, 2026

    @theemathas
    ContributorAuthor

    @Dnreikronos I worry that doing any logic involving alignment of types might cause a semver hazard.

    That is, an upstream crate might define a type. A downstream crate then puts that type into a #[pin_v2] #[repr(packed)] struct. Later, the upstream crate updates private fields in that type, which increases the alignment. This causes the downstream crate to break. This happens even though it might not be obvious to the downstream crate's author that they're relying on the alignment of the upstream crate's type.

    Therefore, I'm in favor of just banning #[pin_v2] on a #[repr(packed)] type. I doubt this would affect many use cases anyway.

  6. Dnreikronos commented on Jun 3, 2026

    @Dnreikronos
    Contributor

    @Dnreikronos I worry that doing any logic involving alignment of types might cause a semver hazard.

    That is, an upstream crate might define a type. A downstream crate then puts that type into a #[pin_v2] #[repr(packed)] struct. Later, the upstream crate updates private fields in that type, which increases the alignment. This causes the downstream crate to break. This happens even though it might not be obvious to the downstream crate's author that they're relying on the alignment of the upstream crate's type.

    Therefore, I'm in favor of just banning #[pin_v2] on a #[repr(packed)] type. I doubt this would affect many use cases anyway.

    Yeah, good observation on the semver hazard. That pretty much kills the per-field check for me. If compiling depends on a type's alignment, then an upstream crate bumping its alignment in a patch release would quietly break downstream code, and alignment was never something you're supposed to rely on anyway.
    I can put up a PR for it, unless someone on the lang or compiler side wants to take it a different way.

  7. theemathas commented on Jun 3, 2026

    @theemathas
    ContributorAuthor

    @Dnreikronos On second thought, I'm not sure if the semver hazard is a big deal. #[repr(packed)] already has a semver hazard. Creating a reference to a packed struct's field causes an alignment check:

    // Change u8 to u16 to make the code stop compiling
    struct One(u8);
    impl One {
        fn new() -> Self {
            One(0)
        }
    }
    
    // Imagine that the above code is in a separate crate.
    
    #[repr(packed)]
    struct Two(One);
    
    fn main() {
        let x = Two(One::new());
        let y = &x.0;
    }

    Not sure what the best way to implement #[pin_v2] would be...

  8. Dnreikronos commented on Jun 3, 2026

    @Dnreikronos
    Contributor

    @Dnreikronos On second thought, I'm not sure if the semver hazard is a big deal. #[repr(packed)] already has a semver hazard. Creating a reference to a packed struct's field causes an alignment check:

    // Change u8 to u16 to make the code stop compiling
    struct One(u8);
    impl One {
    fn new() -> Self {
    One(0)
    }
    }

    // Imagine that the above code is in a separate crate.

    #[repr(packed)]
    struct Two(One);

    fn main() {
    let x = Two(One::new());
    let y = &x.0;
    }
    Not sure what the best way to implement #[pin_v2] would be...

    I will study more about it and I will send the info here to take your opinion

  9. Dnreikronos commented on Jun 3, 2026

    @Dnreikronos
    Contributor

    Hey @theemathas, ok so I studied this a bit more, here's where I'm at.

    The main thing I missed before: the per-field align check (the idea where we'd only reject a packed pin_v2 struct if some field's align could go over the pack) doesn't actually save the repro at all. One<T> is generic, so there's no way to prove align(T) <= N at definition time, which means we'd end up rejecting it anyway. So that check would really only keep the narrow case alive where the struct is fully concrete and every field is already align <= N. That's a pretty niche set tbh, and we'd be paying for it with alignment-dependent compilation (the semver thing you mentioned), so imo not really worth it.

    The only thing that would actually keep One<T> compiling is checking at the projection site instead of at the definition. But that one needs layout info right where the projection happens, and typeck runs before layout, plus the post-mono cases... it's a fair bit of work for an experimental feature and idk if it's worth doing right now.

    So yeah, I'm leaning back towards just banning #[pin_v2] on #[repr(packed)], like pin-project does (pin-project-lite too, though it leans on a lint trick that has a known align-1 hole, see taiki-e/pin-project-lite#89). It's sound, it's tiny to implement (basically a flag check next to the existing packed checks, no align logic at all, so no new semver hazard), and the part I like is that it doesn't lock us in: if we ever want the permissive projection-site version later, that one only ever accepts more code, so adding it wouldn't break anyone.

    Lmk what you think. If you're good with the ban I'll put up a PR for it.

  10. theemathas commented on Jun 3, 2026

    @theemathas
    ContributorAuthor

    I'm not the one who's deciding this, but banning packed structs sounds reasonable to me.

  11. added a commit that references this issue on Jun 8, 2026
    e43ec0c
  12. added a commit that references this issue on Jun 9, 2026
    4a56cbb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-destructorsArea: Destructors (`Drop`, …)A-reprArea: the `#[repr(stuff)]` attributeA-repr-packedArea: the naughtiest reprC-bugCategory: This is a bug.F-pin_ergonomics`#![feature(pin_ergonomics)]`I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.T-langRelevant to the language team

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions