From 3c58f15f549503db82b6568df592e8ad9f3ed200 Mon Sep 17 00:00:00 2001 From: Andrew Z <1497456+z-tech@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:39:56 +0200 Subject: [PATCH 1/4] [derive] Permit IntoBytes on same-type generics A `#[repr(C)]` struct with two or more fields whose types all share the same syntactic token tree has no padding: each field sits at offset `i * size_of::()`, which is a multiple of `align_of::()` (the struct's alignment), and the struct's size `N * size_of::()` is also a multiple of that alignment. Requiring only `T: IntoBytes` is therefore sufficient to prove that the struct is padding-free. This accepts strictly more types than the existing generic `repr(C)` branch, which demands that every field type also implement `Unaligned`. The new branch applies when every field type token-compares equal; syntactic equality keeps the rule trivially sound without needing type-resolution machinery that is unavailable to proc macros. Partially addresses #10. --- zerocopy-derive/src/derive/into_bytes.rs | 43 ++++++++++++++++++- ...tes_struct_homogeneous_generic.expected.rs | 21 +++++++++ ...t_homogeneous_generic_assoc_ty.expected.rs | 22 ++++++++++ zerocopy-derive/src/output_tests/mod.rs | 35 +++++++++++++++ zerocopy-derive/tests/struct_to_bytes.rs | 31 +++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic.expected.rs create mode 100644 zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic_assoc_ty.expected.rs diff --git a/zerocopy-derive/src/derive/into_bytes.rs b/zerocopy-derive/src/derive/into_bytes.rs index 8c1e1009dd..e2aca8118f 100644 --- a/zerocopy-derive/src/derive/into_bytes.rs +++ b/zerocopy-derive/src/derive/into_bytes.rs @@ -1,5 +1,5 @@ use proc_macro2::{Span, TokenStream}; -use quote::quote; +use quote::{quote, ToTokens}; use syn::{Data, DataEnum, DataStruct, DataUnion, Error, Type}; use crate::{ @@ -68,6 +68,23 @@ fn derive_into_bytes_struct(ctx: &Ctx, strct: &DataStruct) -> Result()`, field `i` sits at offset `i * + // size_of::()` (always a multiple of `align_of::()`), and + // the struct's total size is `N * size_of::()` (also a + // multiple of its alignment). Hence there is no inter-field or + // trailing padding, and requiring `T: IntoBytes` (so that `T` + // itself is padding-free) proves the struct is padding-free. + // + // We prefer this to the `Unaligned`-requiring branch below + // because it accepts strictly more types without giving up any + // soundness guarantees. + (None, false) } else if is_c && !repr.is_align_gt_1() { // We can't use a padding check since there are generic type arguments. // Instead, we require all field types to implement `Unaligned`. This @@ -96,6 +113,30 @@ fn derive_into_bytes_struct(ctx: &Ctx, strct: &DataStruct) -> Result::Self_` compare unequal even if they resolve to the +/// same type. This is deliberate. A proc macro has no type resolution, +/// and the soundness argument the caller relies on (that `N` copies of +/// the same type under `repr(C)` contain no padding) only holds when +/// the types are actually the same at the Rust-layout level. Being +/// strict about token equality keeps the rule trivially sound. +fn all_fields_same_type(strct: &DataStruct) -> bool { + let fields = strct.fields(); + if fields.len() < 2 { + // Zero- and one-field cases are already handled by an earlier + // branch in `derive_into_bytes_struct`. Returning `false` here + // ensures those cases cannot reach this branch even if the + // order of checks is later rearranged. + return false; + } + let mut tokens = fields.iter().map(|(_, _, ty)| ty.to_token_stream().to_string()); + let first = tokens.next().expect("len >= 2"); + tokens.all(|t| t == first) +} + fn derive_into_bytes_enum(ctx: &Ctx, enm: &DataEnum) -> Result { let repr = EnumRepr::from_attrs(&ctx.ast.attrs)?; if !repr.is_c() && !repr.is_primitive() { diff --git a/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic.expected.rs b/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic.expected.rs new file mode 100644 index 0000000000..c9dbf0453a --- /dev/null +++ b/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic.expected.rs @@ -0,0 +1,21 @@ +#[allow( + deprecated, + private_bounds, + non_local_definitions, + non_camel_case_types, + non_upper_case_globals, + non_snake_case, + non_ascii_idents, + clippy::missing_inline_in_public_items, +)] +#[deny(ambiguous_associated_items)] +#[automatically_derived] +const _: () = { + unsafe impl ::zerocopy::IntoBytes for Foo + where + T: ::zerocopy::IntoBytes, + T: ::zerocopy::IntoBytes, + { + fn only_derive_is_allowed_to_implement_this_trait() {} + } +}; diff --git a/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic_assoc_ty.expected.rs b/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic_assoc_ty.expected.rs new file mode 100644 index 0000000000..e787acfb9c --- /dev/null +++ b/zerocopy-derive/src/output_tests/expected/into_bytes_struct_homogeneous_generic_assoc_ty.expected.rs @@ -0,0 +1,22 @@ +#[allow( + deprecated, + private_bounds, + non_local_definitions, + non_camel_case_types, + non_upper_case_globals, + non_snake_case, + non_ascii_idents, + clippy::missing_inline_in_public_items, +)] +#[deny(ambiguous_associated_items)] +#[automatically_derived] +const _: () = { + unsafe impl ::zerocopy::IntoBytes for Foo

+ where + P::BaseField: ::zerocopy::IntoBytes, + P::BaseField: ::zerocopy::IntoBytes, + P::BaseField: ::zerocopy::IntoBytes, + { + fn only_derive_is_allowed_to_implement_this_trait() {} + } +}; diff --git a/zerocopy-derive/src/output_tests/mod.rs b/zerocopy-derive/src/output_tests/mod.rs index 836eb6c0a8..a75c1da759 100644 --- a/zerocopy-derive/src/output_tests/mod.rs +++ b/zerocopy-derive/src/output_tests/mod.rs @@ -232,6 +232,41 @@ fn test_into_bytes_struct_trailing() { } } +#[test] +fn test_into_bytes_struct_homogeneous_generic() { + // A `#[repr(C)]` generic struct whose fields all share the same + // syntactic type has no padding, so the emitted `IntoBytes` impl + // bounds only on `IntoBytes` for the shared type and omits the + // `Unaligned` bound that the fallback branch would otherwise add. + test! { + IntoBytes { + #[repr(C)] + struct Foo { + a: T, + b: T, + } + } expands to "expected/into_bytes_struct_homogeneous_generic.expected.rs" + } +} + +#[test] +fn test_into_bytes_struct_homogeneous_generic_assoc_ty() { + // Exercises the case in which every field is an associated-type + // projection of the single type parameter. Token comparison sees + // the same sequence `P :: BaseField` for every field, so the + // homogeneous branch applies. + test! { + IntoBytes { + #[repr(C)] + struct Foo { + c0: P::BaseField, + c1: P::BaseField, + c2: P::BaseField, + } + } expands to "expected/into_bytes_struct_homogeneous_generic_assoc_ty.expected.rs" + } +} + #[test] fn test_into_bytes_struct_trailing_generic() { test! { diff --git a/zerocopy-derive/tests/struct_to_bytes.rs b/zerocopy-derive/tests/struct_to_bytes.rs index 40fa2e5a91..6bb9413c37 100644 --- a/zerocopy-derive/tests/struct_to_bytes.rs +++ b/zerocopy-derive/tests/struct_to_bytes.rs @@ -160,6 +160,37 @@ struct ReprCGenericOneField { util_assert_impl_all!(ReprCGenericOneField: imp::IntoBytes); util_assert_impl_all!(ReprCGenericOneField<[util::AU16]>: imp::IntoBytes); +// When every field of a generic `repr(C)` struct has the same syntactic +// type, the struct has no padding regardless of the alignment of that +// type, so `IntoBytes` can be derived requiring only that the shared +// type itself is `IntoBytes` (no `Unaligned` bound needed). + +#[derive(imp::IntoBytes)] +#[zerocopy(crate = "zerocopy_renamed")] +#[repr(C)] +struct ReprCGenericHomogeneousTuple(T, T); + +util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); +util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); +// `AU16` has alignment 2, so the fallback branch for generic `repr(C)` +// would have required `AU16: Unaligned` and been rejected. See the +// corresponding `util_assert_not_impl_any!` on +// `ReprCGenericMultipleFields<_, AU16>` below. +util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); + +#[derive(imp::IntoBytes)] +#[zerocopy(crate = "zerocopy_renamed")] +#[repr(C)] +struct ReprCGenericHomogeneousNamed { + x: T, + y: T, + z: T, +} + +util_assert_impl_all!(ReprCGenericHomogeneousNamed: imp::IntoBytes); +util_assert_impl_all!(ReprCGenericHomogeneousNamed: imp::IntoBytes); +util_assert_impl_all!(ReprCGenericHomogeneousNamed: imp::IntoBytes); + #[derive(imp::IntoBytes)] #[zerocopy(crate = "zerocopy_renamed")] #[repr(C)] From 0c484b0a1508691233f8ff17d9f550ec9f6b00ee Mon Sep 17 00:00:00 2001 From: Andrew Zitek-Estrada <1497456+z-tech@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:03:21 +0200 Subject: [PATCH 2/4] Update zerocopy-derive/src/derive/into_bytes.rs Co-authored-by: Jack Wrenn --- zerocopy-derive/src/derive/into_bytes.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/zerocopy-derive/src/derive/into_bytes.rs b/zerocopy-derive/src/derive/into_bytes.rs index e2aca8118f..906089fa4a 100644 --- a/zerocopy-derive/src/derive/into_bytes.rs +++ b/zerocopy-derive/src/derive/into_bytes.rs @@ -125,16 +125,13 @@ fn derive_into_bytes_struct(ctx: &Ctx, strct: &DataStruct) -> Result bool { let fields = strct.fields(); - if fields.len() < 2 { - // Zero- and one-field cases are already handled by an earlier - // branch in `derive_into_bytes_struct`. Returning `false` here - // ensures those cases cannot reach this branch even if the - // order of checks is later rearranged. - return false; + let mut fields = + strct.fields().into_iter().map(|(_, _, ty)| ty.into_token_stream().to_string()); + if let Some(first) = fields.next() { + fields.all(|field| field == first) + } else { + true } - let mut tokens = fields.iter().map(|(_, _, ty)| ty.to_token_stream().to_string()); - let first = tokens.next().expect("len >= 2"); - tokens.all(|t| t == first) } fn derive_into_bytes_enum(ctx: &Ctx, enm: &DataEnum) -> Result { From 748424e91763112647bebd8098141b7d2b2f95a4 Mon Sep 17 00:00:00 2001 From: Andrew Z <1497456+z-tech@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:07:10 +0200 Subject: [PATCH 3/4] Address review: drop redundant comments --- zerocopy-derive/src/derive/into_bytes.rs | 11 ----------- zerocopy-derive/tests/struct_to_bytes.rs | 4 ---- 2 files changed, 15 deletions(-) diff --git a/zerocopy-derive/src/derive/into_bytes.rs b/zerocopy-derive/src/derive/into_bytes.rs index 906089fa4a..4daf7fea85 100644 --- a/zerocopy-derive/src/derive/into_bytes.rs +++ b/zerocopy-derive/src/derive/into_bytes.rs @@ -113,18 +113,7 @@ fn derive_into_bytes_struct(ctx: &Ctx, strct: &DataStruct) -> Result::Self_` compare unequal even if they resolve to the -/// same type. This is deliberate. A proc macro has no type resolution, -/// and the soundness argument the caller relies on (that `N` copies of -/// the same type under `repr(C)` contain no padding) only holds when -/// the types are actually the same at the Rust-layout level. Being -/// strict about token equality keeps the rule trivially sound. fn all_fields_same_type(strct: &DataStruct) -> bool { - let fields = strct.fields(); let mut fields = strct.fields().into_iter().map(|(_, _, ty)| ty.into_token_stream().to_string()); if let Some(first) = fields.next() { diff --git a/zerocopy-derive/tests/struct_to_bytes.rs b/zerocopy-derive/tests/struct_to_bytes.rs index 6bb9413c37..f5dd2de9bf 100644 --- a/zerocopy-derive/tests/struct_to_bytes.rs +++ b/zerocopy-derive/tests/struct_to_bytes.rs @@ -172,10 +172,6 @@ struct ReprCGenericHomogeneousTuple(T, T); util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); -// `AU16` has alignment 2, so the fallback branch for generic `repr(C)` -// would have required `AU16: Unaligned` and been rejected. See the -// corresponding `util_assert_not_impl_any!` on -// `ReprCGenericMultipleFields<_, AU16>` below. util_assert_impl_all!(ReprCGenericHomogeneousTuple: imp::IntoBytes); #[derive(imp::IntoBytes)] From 1abe7d0711019ff0155bf7924f2a21bf2bbee940 Mon Sep 17 00:00:00 2001 From: Andrew Z <1497456+z-tech@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:09:29 +0200 Subject: [PATCH 4/4] Restore shadowed fields binding per suggestion --- zerocopy-derive/src/derive/into_bytes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zerocopy-derive/src/derive/into_bytes.rs b/zerocopy-derive/src/derive/into_bytes.rs index 4daf7fea85..3c5c06fda2 100644 --- a/zerocopy-derive/src/derive/into_bytes.rs +++ b/zerocopy-derive/src/derive/into_bytes.rs @@ -114,8 +114,8 @@ fn derive_into_bytes_struct(ctx: &Ctx, strct: &DataStruct) -> Result bool { - let mut fields = - strct.fields().into_iter().map(|(_, _, ty)| ty.into_token_stream().to_string()); + let fields = strct.fields(); + let mut fields = fields.into_iter().map(|(_, _, ty)| ty.into_token_stream().to_string()); if let Some(first) = fields.next() { fields.all(|field| field == first) } else {