Visitar URL original
Fix pyclass memory layout to prevent silent UB in inherited getter dispatch by 1ndahous3 · Pull Request #7663 · RustPython/RustPython · GitHub
Skip to content

Fix pyclass memory layout to prevent silent UB in inherited getter dispatch - #7663

Merged
youknowone merged 3 commits into
RustPython:mainfrom
1ndahous3:pyclass_mem_layout
Apr 24, 2026
Merged

youknowone merged 3 commits into
RustPython:mainfrom
1ndahous3:pyclass_mem_layout

Conversation

@1ndahous3

@1ndahous3 1ndahous3 commented Apr 23, 2026 •

Copy link
Copy Markdown
Contributor

Derived #[pyclass] structs that mix pointer-sized and non-pointer-sized fields are silently miscompiled: Rust's default #[repr(Rust)] layout algorithm places the base field at a non-zero offset, while the inherited getter dispatcher unconditionally assumes it is at offset 0.

The result is undefined behaviour that manifests as a SIGSEGV at runtime.

This PR fixes the root cause by:

  1. Auto-inserting #[repr(C)] on derived structs that carry no explicit repr (guarantees declaration-order layout, keeping the base field at offset 0).
  2. Emitting a const_assert via offset_of! as belt-and-suspenders: catches the remaining case where a user supplies an explicit repr that does not guarantee offset 0.

Here is my minimal code that causes SIGSEGV/ACCESS_VIOLATION on Windows/Linux:

use rustpython_vm::Interpreter;
use rustpython_vm::PyObjectRef;
use rustpython_vm::pyclass;
use rustpython_vm::class::PyClassImpl;
use rustpython_vm::PyPayload;

fn main() {

    #[pyclass(module = false, name = "Base")]
    #[derive(Debug, PyPayload)]
    struct Base {
        field: PyObjectRef,
    }

    #[pyclass]
    impl Base {
        #[pygetset]
        fn field(&self) -> PyObjectRef {
            self.field.clone()
        }
    }

    #[pyclass(module = false, name = "Derived", base = Base)]
    #[derive(Debug)]
    struct Derived { // No #[repr(C)] — triggers the bug
        _base: Base,
        extra: Option<u64>,  // displaces _base in memory under #[repr(Rust)]
    }

    #[pyclass]
    impl Derived {
        #[pygetset]
        fn extra(&self) -> Option<u64> {
            self.extra
        }

        // No `field` getter — relies on inherited Base getter -> crash
    }

    let interp = Interpreter::without_stdlib(Default::default());
    interp.enter(|vm| {
        Base::make_static_type();
        Derived::make_static_type();

        let obj = vm.new_pyobj(Derived {
            _base: Base { field: vm.ctx.new_int(42).into() },
            extra: Some(99),
        });

        // Accessing `extra` (own getter) is fine.
        let _extra = obj.get_attr("extra", vm).expect("extra getter ok");

        // Accessing `field` (inherited getter) -> SIGSEGV without the fix
        let _field = obj.get_attr("field", vm).expect("field getter ok");
    });
}

Summary by CodeRabbit

  • Refactor
    • Strengthened compile-time validation of struct memory layout for base classes, now detecting and validating the primary base field and asserting correct offsets.
    • Automatically enforce a compatible memory representation when a base is declared so derived types meet layout expectations.
    • Removed several explicit memory-layout annotations from internal types to streamline layout handling and reduce redundant declarations.

@coderabbitai

coderabbitai Bot commented Apr 23, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 44fce56b-d978-4f1e-bfee-85b3226f8600

📥 Commits

Reviewing files that changed from the base of the PR and between 2980ad1 and 280ecd3.

📒 Files selected for processing (1)
  • crates/derive-impl/src/pyclass.rs

📝 Walkthrough

Walkthrough

The derive macro in crates/derive-impl/src/pyclass.rs now identifies the struct field used as base, can inject #[repr(C)] when a base is present, and emits an offset_of! assertion requiring that base field be at offset 0. Explicit #[repr(C)] attributes were removed from PyFuture, PyTask, and PyNativeMethod.

Changes

Cohort / File(s) Summary
Macro: base handling & repr insertion
crates/derive-impl/src/pyclass.rs
Base validation now returns the base-field token (identifier or tuple index); macro conditionally injects #[repr(C)] if no explicit repr exists, re-parses/re-derives identifiers/attributes, and emits a compile-time offset_of! assertion enforcing base at offset 0.
Removed explicit repr attributes
crates/stdlib/src/_asyncio.rs, crates/vm/src/builtins/builtin_func.rs
Deleted explicit #[repr(C)] annotations and accompanying layout comments from PyFuture, PyTask, and PyNativeMethod; layout assertions are handled by the derive macro when applicable.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through structs and fields with care,
I tucked in reprs so bases sit where they dare,
I asked the compiler, "Check offset at zero!"
Now memory hums steady — from burrow to bureau. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: preventing undefined behavior in inherited getter dispatch by correcting pyclass memory layout handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/derive-impl/src/pyclass.rs (2)

337-353: Named-field branch silently skips the assertion if ident is None.

For syn::Fields::Named, first_field.ident is guaranteed to be Some by the parser, so ident.as_ref().map(|id| quote! { #id }) returning None is unreachable in practice. Since the caller treats Ok(None) as "no assertion to emit", a logic bug here would silently disable the layout safety net instead of erroring. Consider expect‑ing the invariant or returning Some unconditionally so the guarantee is explicit:

♻️ Make the invariant explicit
-            let ident = first_field.ident.as_ref().map(|id| quote! { `#id` });
-            Ok(ident)
+            let ident = first_field
+                .ident
+                .as_ref()
+                .expect("syn::Fields::Named always has field idents");
+            Ok(Some(quote! { `#ident` }))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/derive-impl/src/pyclass.rs` around lines 337 - 353, In the named-field
branch of the match (handling syn::Fields::Named), the current mapping of
first_field.ident to ident can produce Ok(None) which silently disables the
assertion; make the parser invariant explicit by unwrapping/expect-ing
first_field.ident (e.g., use first_field.ident.as_ref().expect(...)) and return
an owned Some(quote! { `#id` }) so the function returns Ok(Some(...)) instead of
Ok(None); this ensures the #[pyclass] base-type assertion (checked via
type_matches_path and first_field) cannot be bypassed silently.

384-394: ensure_repr_c opts out on any #[repr(...)], including ones that don't guarantee offset‑0.

has_repr matches any repr attribute, so a user who writes only #[repr(align(N))] (or #[repr(packed)] without C) will not get #[repr(C)] auto‑inserted even though those reprs alone don't pin the base field to offset 0 under current Rust layout rules. In practice the offset_of! assertion emitted at lines 606–619 catches this at compile time, so this is not a correctness gap — just worth being explicit about in the comment so future maintainers don't assume ensure_repr_c alone is sufficient.

Optionally, you could narrow the opt‑out to reprs that already guarantee declaration order (C, transparent) and still layer the assert on top:

♻️ Narrower opt‑out
-    let has_repr = s.attrs.iter().any(|attr| attr.path().is_ident("repr"));
-    if !has_repr {
+    // Only treat reprs that guarantee declaration order as an opt-out;
+    // `align`/`packed` alone do not pin the base field to offset 0.
+    let has_layout_repr = s.attrs.iter().any(|attr| {
+        if !attr.path().is_ident("repr") {
+            return false;
+        }
+        let mut found = false;
+        let _ = attr.parse_nested_meta(|meta| {
+            if meta.path.is_ident("C") || meta.path.is_ident("transparent") {
+                found = true;
+            }
+            Ok(())
+        });
+        found
+    });
+    if !has_layout_repr {
         let repr_c: syn::Attribute = parse_quote!(#[repr(C)]);
         s.attrs.push(repr_c);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/derive-impl/src/pyclass.rs` around lines 384 - 394, ensure_repr_c
currently treats any #[repr(...)] as reason to skip adding #[repr(C)], which
incorrectly skips structs that use #[repr(align(...))] or #[repr(packed)] that
don't guarantee declaration-order layout; update ensure_repr_c so it only opts
out when an existing repr explicitly guarantees declaration order (i.e., path
is_ident "repr" and contains "C" or "transparent"), otherwise push #[repr(C)] as
before; also add a short clarifying comment in the ensure_repr_c function noting
that the offset_of! assertion elsewhere still protects against remaining cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/derive-impl/src/pyclass.rs`:
- Around line 337-353: In the named-field branch of the match (handling
syn::Fields::Named), the current mapping of first_field.ident to ident can
produce Ok(None) which silently disables the assertion; make the parser
invariant explicit by unwrapping/expect-ing first_field.ident (e.g., use
first_field.ident.as_ref().expect(...)) and return an owned Some(quote! { `#id` })
so the function returns Ok(Some(...)) instead of Ok(None); this ensures the
#[pyclass] base-type assertion (checked via type_matches_path and first_field)
cannot be bypassed silently.
- Around line 384-394: ensure_repr_c currently treats any #[repr(...)] as reason
to skip adding #[repr(C)], which incorrectly skips structs that use
#[repr(align(...))] or #[repr(packed)] that don't guarantee declaration-order
layout; update ensure_repr_c so it only opts out when an existing repr
explicitly guarantees declaration order (i.e., path is_ident "repr" and contains
"C" or "transparent"), otherwise push #[repr(C)] as before; also add a short
clarifying comment in the ensure_repr_c function noting that the offset_of!
assertion elsewhere still protects against remaining cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 91b82e56-aea9-4877-885c-ff2bee16fcd8

📥 Commits

Reviewing files that changed from the base of the PR and between 5081f76 and 48311be.

📒 Files selected for processing (3)
  • crates/derive-impl/src/pyclass.rs
  • crates/stdlib/src/_asyncio.rs
  • crates/vm/src/builtins/builtin_func.rs
💤 Files with no reviewable changes (2)
  • crates/vm/src/builtins/builtin_func.rs
  • crates/stdlib/src/_asyncio.rs

Comment thread crates/derive-impl/src/pyclass.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/derive-impl/src/pyclass.rs (1)

378-394: Auto-inserted #[repr(C)] + compile-time offset_of! assertion is a solid two-layer defense.

The combination of (a) defaulting bare #[pyclass] derived structs to #[repr(C)] and (b) emitting a const _: () offset_of! assertion for any struct that already carries an explicit repr is a good belt-and-suspenders approach: the common case gets a correct layout for free, and the pathological case fails at compile time instead of segfaulting at runtime.

One minor nit on the diagnostic text in the const assert (Lines 617-621): #[repr(transparent)] is only valid for structs with a single non-ZST field, so suggesting it alongside #[repr(C)] as an equally-good fix could mislead users of multi-field derived classes (which is exactly the case the PR is motivated by). Consider softening the suggestion, e.g. "Add #[repr(C)] (or remove the explicit repr so the macro inserts it)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/derive-impl/src/pyclass.rs` around lines 378 - 394, Update the
diagnostic text emitted alongside the compile-time `offset_of!` assertion so it
does not suggest `#[repr(transparent)]` as an equally-valid fix for multi-field
structs; instead soften the suggestion to something like "Add `#[repr(C)]` (or
remove the explicit repr so the macro inserts it)". Locate the code that
generates the `const _: ()` `offset_of!` assertion (the code that builds the
diagnostic string next to the `offset_of!` check for explicit reprs — referenced
in this diff as the assertion emitted when an explicit repr is present) and
replace the message text accordingly; keep the rest of the assertion logic
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/derive-impl/src/pyclass.rs`:
- Around line 378-394: Update the diagnostic text emitted alongside the
compile-time `offset_of!` assertion so it does not suggest
`#[repr(transparent)]` as an equally-valid fix for multi-field structs; instead
soften the suggestion to something like "Add `#[repr(C)]` (or remove the
explicit repr so the macro inserts it)". Locate the code that generates the
`const _: ()` `offset_of!` assertion (the code that builds the diagnostic string
next to the `offset_of!` check for explicit reprs — referenced in this diff as
the assertion emitted when an explicit repr is present) and replace the message
text accordingly; keep the rest of the assertion logic intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 0b5c333e-4a4f-47d8-aea0-aa944364fdf4

📥 Commits

Reviewing files that changed from the base of the PR and between 48311be and 2980ad1.

📒 Files selected for processing (1)
  • crates/derive-impl/src/pyclass.rs

@fanninpm

Copy link
Copy Markdown
Contributor

@youknowone @ShaharNaveh on a previous run, reviewdog complained:

reviewdog: failed to post a review comment: POST https://api.github.com/repos/RustPython/RustPython/pulls/7663/reviews: 403 Resource not accessible by integration []

reviewdog: This GitHub Token doesn't have write permission of Review API [1],
so reviewdog will report results via logging command [2] and create annotations similar to
github-pr-check reporter as a fallback.
[1]: https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target
[2]: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions

Co-authored-by: fanninpm <27117322+fanninpm@users.noreply.github.com>
@fanninpm
fanninpm requested a review from youknowone April 23, 2026 18:23

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question about the current situation. As I understand it, #[repr(C)] is only required for types that have derived implementations. Is that correct?
If even base types without any derived implementations also require #[repr(C)], then I would agree with this patch. Otherwise, I would prefer to keep #[repr(C)] explicit, and rather than having the macro automatically add #[repr(C)], I would prefer it to emit a warning when a base type is missing #[repr(C)].

@1ndahous3

Copy link
Copy Markdown
Contributor Author

@youknowone no, this fix is ​​for derived types, not base types (nothing changes for them), but yes, #[repr(C)] is set for all derived classes where no #[repr( is explicitly specified. Base classes are unaffected. I figured that setting an unconditional #[repr(C)] (even on those derived classes where we're lucky and Rust doesn't optimize the layout) wouldn't be a bad thing.

If you don't like this mechanic, I see two other options:

  1. Remove the implicit addition of #[repr(C)] and leave only the assertion for the actual incorrect offset. Then we'll have to set #[repr(C)] manually in derived classes, but not always.
  2. Try adding #[repr(C)] only if no other #[repr is specified (as is the case now) and we've determined that we have an incorrect layout (however, the check will need to be applied twice: as a trigger for adding the #[repr(C)] and as a final assert to ensure the final layout is correct.

Honestly, I'd keep the current fix, as I don't see anything wrong with unconditionally #[repr(C)] for all derived classes.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the explanation.

@youknowone
youknowone merged commit 7bb2fb0 into RustPython:main Apr 24, 2026
20 checks passed
@youknowone

Copy link
Copy Markdown
Member

And welcome to RustPython project!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants