Skip to content

Unsoundness: explicit pin projection into a #[repr(packed)] field breaks Pin without #[pin_v2] #157615

Closed

Description

@Dnreikronos

So when the destructor of a repr(packed) struct runs, a field that's unaligned and has a destructor gets moved to an aligned place before that field's destructor runs. (See also #157011, #143411, taiki-e/pin-project-lite#89, taiki-e/pin-project#34.)

#157011 covers this for #[pin_v2], and the fix there (#157542) just bans #[pin_v2] + #[repr(packed)]. But afaict the #[pin_v2] attribute only gates the implicit projection (the default binding-mode shift, like plain Foo { x, y } matched against a Pin<&mut Foo>). The explicit &pin mut / ref pin mut patterns project in place no matter whether the type opted in, so banning the attribute combo doesn't really close this. You can hit the exact same move-on-drop with no #[pin_v2] anywhere. Filing this as the standalone issue for the leftover case that #157542 left open under the pin ergonomics tracking issue #130494.

The projection soundness check lives in rustc_hir_typeck/src/pat.rs and only runs under default_binding_modes. Spelling the deref out (&pin mut .. ref pin mut ..) kinda walks right past it, which is why no attribute is needed and why the #157011 fix doesn't catch this.

I tried this code:

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

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

// no #[pin_v2] on either type
#[repr(C, packed(4))]
struct One<T>(T);

#[repr(C, align(4096))]
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}");
    }
}

I expected to see this happen: the explicit &pin mut / ref pin mut projection through a packed type should get rejected, like the #[pin_v2] case in #157011. A Pin<&mut Thing> you hand out should sure keep a stable address for the rest of the value's life.

Instead, this happened: it just compiles and runs, and the pinned-access addr and the drop addr come out different, so the Pin invariant is broken. One is packed(4), so its Thing leaf is aligned to 4. That's fine on its own, so unaligned_references doesn't complain. The thing that actually moves on drop is the parent Two (align 4096), and we never take a reference to Two, so nothing catches it. (One has to be generic to dodge E0588.)

Couple more notes: I used align(4096) instead of #157011's 65536 just so it prints cleanly. At 65536 it straight up segfaults on my machine (stack realignment during the drop-glue move), same unsoundness, just louder. Also the &pin mut <place> borrow operator (like &pin mut one.0.0) does not repro this, imo because it copies the packed field out to an aligned temp first, so it stays safe. It's specifically the pattern form that pins in place.

Meta

rustc --version --verbose:

rustc 1.98.0-nightly (f20a92ec0 2026-06-07)
binary: rustc
commit-hash: f20a92ec01483dc5c58e90e246f266bdad822d86
commit-date: 2026-06-07
host: x86_64-pc-windows-msvc
release: 1.98.0-nightly
LLVM version: 22.1.6

Activity

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Jun 8, 2026
  2. Dnreikronos commented on Jun 8, 2026

    @Dnreikronos
    Author
  3. added
    A-alignArea: alignment control (`repr(align(N))` and so on)
    A-destructorsArea: Destructors (`Drop`, …)
    I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness
    on Jun 8, 2026
  4. deleted a comment from rustbot on Jun 8, 2026
  5. deleted a comment from rustbot on Jun 8, 2026
  6. Dnreikronos commented on Jun 8, 2026

    @Dnreikronos
    ContributorAuthor

    Invested some time on this a bit. the check that should catch it (ProjectOnNonPinProjectType in pat.rs) is gated on pat.default_binding_modes, so it only runs for the implicit projection. explicit &pin mut / ref pin mut go through check_pat_ref -> check_pat_tuple_struct/check_pat_struct, which dont do any pin or packed check at all (no packed anywhere in pat.rs).

    so two things fall out, not just the packed bug: the hole here, and explicit projection skiping #[pin_v2] entirely. idk if that second ones a bug or intended (explicit = the manual path that doesn't need the attr?), and that kinda decides the fix. this compiles today, nothing packed, no #[pin_v2]:

    #[repr(C)]
    struct One<T>(T);
    let &pin mut One(Two(ref pin mut x)) = pinned_one; // accepted

    good thing is the signal's alredy there: check_pat_ref sets pat_info.max_pinnedness = Pinned after peeling &pin mut, and it flows down to every subpattern (the binding site already uses it for the Unpin bound). so the only new bit is adt.repr().packed().

    three ways imo:

    (1) gate the explicit path on #[pin_v2] too, i.e. run ProjectOnNonPinProjectType there as well. #157542 (still open) bans #[pin_v2] + packed, so once that lands this closes the packed hole for free and makes explicit/implicit match. only works if the skip is a bug though.

    (2) blunt packed check at the destructure:

    if pat_info.max_pinnedness == PinnednessCap::Pinned
        && let ty::Adt(adt, _) = pat_ty.kind()
        && adt.repr().packed()
    { /* error: cannot pin-project through a #[repr(packed)] type */ }

    same move as #157542 (ban the combo), matches the upvar.rs capture restriction that stops at a packed field regardless of alignment. one false positive: &pin mut Packed(x) by value is sound but gets rejected. rare, dont care.

    (3) like 2 but precise: thread a "went through packed" flag on PatInfo, reset on deref (like most_packed_projection), error only when a pinned ref's actually formed. no false positives, just more fiddly.

    leaning 2 to start, 3 later if the by-value case ever bites. but if explicit should just require #[pin_v2] like implicit, 1 is cleaner and i'll do that. ltk what you prefer.

    cc @theemathas @mejrs

  7. added
    T-compilerRelevant to the compiler team, which will review and decide on the PR/issue.
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Jun 9, 2026
  8. theemathas commented on Jun 9, 2026

    @theemathas
    Contributor

    I think only approach 1 is the only viable approach, due to #157634.

  9. added a commit that references this issue on Jun 12, 2026
  10. added a commit that references this issue on Jun 12, 2026
  11. theemathas commented on Jun 19, 2026

    @theemathas
    Contributor

    Fixed by #157680

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-alignArea: alignment control (`repr(align(N))` and so on)A-destructorsArea: Destructors (`Drop`, …)A-pinArea: PinA-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.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions