Skip to content

Commit 8a5a468

Browse files
postcard: support trailing opaque fields via #[facet(trailing)] (facet-rs#2109)
## Summary Adds trailing opaque field support for postcard using a generic `#[facet(trailing)]` field attribute. Trailing opaque payloads are emitted and consumed as remaining bytes, without an outer postcard byte-sequence length frame. ## Changes - Add parser capability hint `hint_remaining_byte_sequence()` and use it from generic opaque deserialization when the field is `#[facet(trailing)]`. - Remove format-specific branching from `facet-format` deserialization (no `format_namespace()=="postcard"` gating). - Add `Partial::nearest_field()` and use it for robust field lookup through nested wrapper frames. - Add field-aware opaque serialization dispatch in `facet-format`. - Implement postcard trailing opaque serialization path that writes mapped payload directly (no outer length prefix). - Keep default opaque behavior unchanged for non-trailing fields. - Add built-in `#[facet(trailing)]` attribute and compile-time validation for usage constraints in macros. - Deduplicate trailing validation logic across struct/enum macro processing. - Add integration tests for: - no outer framing for trailing opaque, - trailing deserialization consumes remainder, - scatter-gather reference preservation. ## Testing - `cargo nextest run -p facet-postcard` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` Closes facet-rs#2108
1 parent 17f6404 commit 8a5a468

File tree

11 files changed

+368
-122
lines changed

11 files changed

+368
-122
lines changed

facet-format/src/deserializer/entry.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,27 @@ impl<'parser, 'input, const BORROW: bool> FormatDeserializer<'parser, 'input, BO
239239

240240
Some(DeserStrategy::Opaque) => {
241241
if let Some(adapter) = shape.opaque_adapter {
242-
if self.is_non_self_describing() && !self.parser.hint_byte_sequence() {
243-
self.parser.hint_scalar_type(ScalarTypeHint::Bytes);
242+
let trailing_opaque = wip
243+
.nearest_field()
244+
.is_some_and(|f| f.has_builtin_attr("trailing"));
245+
246+
if self.is_non_self_describing() {
247+
let handled = if trailing_opaque {
248+
self.parser.hint_remaining_byte_sequence()
249+
} else {
250+
self.parser.hint_byte_sequence()
251+
};
252+
if !handled {
253+
self.parser.hint_scalar_type(ScalarTypeHint::Bytes);
254+
}
244255
}
245256

246-
let event = self.expect_event("bytes for opaque adapter")?;
257+
let expected = if trailing_opaque {
258+
"remaining bytes for trailing opaque adapter"
259+
} else {
260+
"bytes for opaque adapter"
261+
};
262+
let event = self.expect_event(expected)?;
247263
let input = match event.kind {
248264
ParseEventKind::Scalar(ScalarValue::Bytes(bytes)) => {
249265
if BORROW {
@@ -259,7 +275,7 @@ impl<'parser, 'input, const BORROW: bool> FormatDeserializer<'parser, 'input, BO
259275
return Err(self.mk_err(
260276
&wip,
261277
DeserializeErrorKind::UnexpectedToken {
262-
expected: "bytes for opaque adapter",
278+
expected,
263279
got: event.kind_name().into(),
264280
},
265281
));

facet-format/src/parser.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,17 @@ pub trait FormatParser<'de> {
179179
false
180180
}
181181

182+
/// Hint to the parser that all remaining input bytes should be consumed as a byte slice.
183+
///
184+
/// This is used by formats like postcard for trailing opaque payloads where the
185+
/// field boundary is "until end of input" rather than a length prefix.
186+
///
187+
/// If handled, the parser should emit `Scalar(Bytes(...))` and advance to EOF.
188+
/// Returns `true` if handled, `false` to use normal deserialization behavior.
189+
fn hint_remaining_byte_sequence(&mut self) -> bool {
190+
false
191+
}
192+
182193
/// Hint to the parser that a fixed-size array is expected.
183194
///
184195
/// For non-self-describing formats, this tells the parser the array length

facet-format/src/serializer.rs

Lines changed: 95 additions & 112 deletions
Large diffs are not rendered by default.

facet-macros-impl/src/process_enum.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::*;
22
use crate::process_struct::{
3-
TraitSources, gen_field_from_pfield, gen_trait_bounds, gen_type_ops, gen_vtable,
4-
phantom_attr_use,
3+
TraitSources, collect_trailing_shape_checks, gen_field_from_pfield, gen_trait_bounds,
4+
gen_type_ops, gen_vtable, phantom_attr_use,
55
};
66
use proc_macro2::Literal;
77
use quote::{format_ident, quote, quote_spanned};
@@ -132,6 +132,7 @@ pub(crate) fn process_enum(parsed: Enum) -> TokenStream {
132132
// Collect phantom use statements for IDE hover support on attribute names.
133133
// These link attribute spans to their facet::builtin::Attr variants.
134134
let mut phantom_attr_uses: Vec<TokenStream> = Vec::new();
135+
let mut trailing_shape_checks: Vec<TokenStream> = Vec::new();
135136
// Container-level attributes
136137
for attr in &pe.container.attrs.facet {
137138
if let Some(phantom) = phantom_attr_use(attr, &facet_crate) {
@@ -151,6 +152,10 @@ pub(crate) fn process_enum(parsed: Enum) -> TokenStream {
151152
PVariantKind::Tuple { fields } => fields,
152153
PVariantKind::Struct { fields } => fields,
153154
};
155+
match collect_trailing_shape_checks(fields, &facet_crate) {
156+
Ok(checks) => trailing_shape_checks.extend(checks),
157+
Err(err) => return err,
158+
}
154159
for field in fields {
155160
if let Some(attr) = field
156161
.attrs
@@ -1361,6 +1366,7 @@ pub(crate) fn process_enum(parsed: Enum) -> TokenStream {
13611366
unsafe impl #bgp_def #facet_crate::Facet<'ʄ> for #enum_name #bgp_without_bounds #where_clauses {
13621367
const SHAPE: &'static #facet_crate::Shape = &const {
13631368
use #facet_crate::𝟋::*;
1369+
#(#trailing_shape_checks)*
13641370
#(#shadow_struct_defs)*
13651371
#fields
13661372
𝟋ShpB::for_sized::<Self>(#enum_name_str)

facet-macros-impl/src/process_struct.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,50 @@ pub(crate) fn phantom_attr_use(
142142
Some(quote! { { use #facet_crate::builtin::Attr::#variant_ident as _; } })
143143
}
144144

145+
pub(crate) fn collect_trailing_shape_checks(
146+
fields: &[PStructField],
147+
facet_crate: &TokenStream,
148+
) -> std::result::Result<Vec<TokenStream>, TokenStream> {
149+
let mut trailing_shape_checks = Vec::new();
150+
151+
for (idx, field) in fields.iter().enumerate() {
152+
for attr in &field.attrs.facet {
153+
if !attr.is_builtin() || attr.key != "trailing" {
154+
continue;
155+
}
156+
if !attr.args.is_empty() {
157+
let span = attr.key.span();
158+
return Err(quote_spanned! { span =>
159+
compile_error!("`#[facet(trailing)]` does not accept arguments");
160+
});
161+
}
162+
if field.attrs.has_builtin("flatten") {
163+
let span = attr.key.span();
164+
return Err(quote_spanned! { span =>
165+
compile_error!("`#[facet(trailing)]` is not supported on flattened fields");
166+
});
167+
}
168+
if idx + 1 != fields.len() {
169+
let span = attr.key.span();
170+
return Err(quote_spanned! { span =>
171+
compile_error!("`#[facet(trailing)]` requires this field to be the last field in its container");
172+
});
173+
}
174+
if !field.attrs.has_builtin("opaque") {
175+
let span = attr.key.span();
176+
let field_type = &field.ty;
177+
trailing_shape_checks.push(quote_spanned! { span =>
178+
if !<#field_type as #facet_crate::Facet<'ʄ>>::SHAPE.has_opaque_adapter() {
179+
panic!("`#[facet(trailing)]` requires an opaque field (`#[facet(opaque)]`) or a field type with `#[facet(opaque = ...)]`");
180+
}
181+
});
182+
}
183+
}
184+
}
185+
186+
Ok(trailing_shape_checks)
187+
}
188+
145189
/// Generates the vtable for a type based on trait sources.
146190
///
147191
/// Uses a layered approach for each trait:
@@ -1471,6 +1515,11 @@ pub(crate) fn process_struct(parsed: Struct) -> TokenStream {
14711515
PStructKind::UnitStruct => &[],
14721516
};
14731517

1518+
let trailing_shape_checks = match collect_trailing_shape_checks(fields, &facet_crate) {
1519+
Ok(checks) => checks,
1520+
Err(err) => return err,
1521+
};
1522+
14741523
// MVP validation: adapter form is container-only for now.
14751524
for field in fields {
14761525
if let Some(attr) = field
@@ -2436,6 +2485,8 @@ pub(crate) fn process_struct(parsed: Struct) -> TokenStream {
24362485
const __SHAPE_DATA: #facet_crate::Shape = {
24372486
use #facet_crate::𝟋::*;
24382487

2488+
#(#trailing_shape_checks)*
2489+
24392490
𝟋ShpB::for_sized::<Self>(#struct_name_str)
24402491
.module_path(::core::module_path!())
24412492
#decl_id_call

facet-postcard/src/parser.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ pub struct PostcardParser<'de> {
9898
pending_sequence: bool,
9999
/// Pending byte sequence flag from `hint_byte_sequence`.
100100
pending_byte_sequence: bool,
101+
/// Pending remaining-bytes flag from `hint_remaining_byte_sequence`.
102+
pending_remaining_bytes: bool,
101103
/// Pending fixed-size array length from `hint_array`.
102104
pending_array: Option<usize>,
103105
/// Pending option flag from `hint_option`.
@@ -138,6 +140,7 @@ impl<'de> PostcardParser<'de> {
138140
pending_scalar_type: None,
139141
pending_sequence: false,
140142
pending_byte_sequence: false,
143+
pending_remaining_bytes: false,
141144
pending_array: None,
142145
pending_option: false,
143146
pending_enum: None,
@@ -322,6 +325,18 @@ impl<'de> PostcardParser<'de> {
322325
return self.parse_opaque_scalar(opaque);
323326
}
324327

328+
// Check if we have a pending trailing bytes hint (consume rest of input as bytes)
329+
if self.pending_remaining_bytes {
330+
self.pending_remaining_bytes = false;
331+
let bytes = &self.input[self.pos..];
332+
self.pos = self.input.len();
333+
return Ok(
334+
self.event(ParseEventKind::Scalar(ScalarValue::Bytes(Cow::Borrowed(
335+
bytes,
336+
)))),
337+
);
338+
}
339+
325340
// Check if we have a pending scalar type hint
326341
if let Some(hint) = self.pending_scalar_type.take() {
327342
return self.parse_scalar_with_hint(hint);
@@ -986,6 +1001,10 @@ impl<'de> FormatParser<'de> for PostcardParser<'de> {
9861001
Some(Span::new(self.pos, 1))
9871002
}
9881003

1004+
fn format_namespace(&self) -> Option<&'static str> {
1005+
Some("postcard")
1006+
}
1007+
9891008
fn save(&mut self) -> SavePoint {
9901009
// Postcard doesn't support save/restore (non-self-describing format)
9911010
// The solver can't work with positional formats anyway
@@ -1050,6 +1069,18 @@ impl<'de> FormatParser<'de> for PostcardParser<'de> {
10501069
true // Postcard supports bulk byte reading
10511070
}
10521071

1072+
fn hint_remaining_byte_sequence(&mut self) -> bool {
1073+
self.pending_remaining_bytes = true;
1074+
if self
1075+
.peeked
1076+
.as_ref()
1077+
.is_some_and(|e| matches!(e.kind, ParseEventKind::OrderedField))
1078+
{
1079+
self.peeked = None;
1080+
}
1081+
true
1082+
}
1083+
10531084
fn hint_array(&mut self, len: usize) {
10541085
self.pending_array = Some(len);
10551086
// Clear any peeked OrderedField placeholder

facet-postcard/src/serialize.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,10 @@ fn map_format_error(error: FormatSerializeError<SerializeError>) -> SerializeErr
457457
}
458458
}
459459

460+
fn has_trailing_attr(field: Option<&facet_core::Field>) -> bool {
461+
field.is_some_and(|f| f.has_builtin_attr("trailing"))
462+
}
463+
460464
struct PostcardSerializer<'a, W> {
461465
writer: W,
462466
_marker: PhantomData<&'a ()>,
@@ -798,6 +802,15 @@ impl<'a, W: PostcardWriter<'a>> FormatSerializer for PostcardSerializer<'a, W> {
798802
&mut self,
799803
shape: &'static facet_core::Shape,
800804
value: Peek<'_, '_>,
805+
) -> Result<bool, Self::Error> {
806+
self.serialize_opaque_scalar_with_field(None, shape, value)
807+
}
808+
809+
fn serialize_opaque_scalar_with_field(
810+
&mut self,
811+
field: Option<&facet_core::Field>,
812+
shape: &'static facet_core::Shape,
813+
value: Peek<'_, '_>,
801814
) -> Result<bool, Self::Error> {
802815
if value.scalar_type().is_some() {
803816
return Ok(false);
@@ -806,10 +819,16 @@ impl<'a, W: PostcardWriter<'a>> FormatSerializer for PostcardSerializer<'a, W> {
806819
if let Some(adapter) = shape.opaque_adapter {
807820
let mapped = unsafe { (adapter.serialize)(value.data()) };
808821
let mapped_peek = unsafe { Peek::unchecked_new(mapped.ptr, mapped.shape) };
809-
let mut bytes = Vec::new();
810-
let mut mapped_serializer = PostcardSerializer::new(CopyWriter::new(&mut bytes));
811-
serialize_root(&mut mapped_serializer, mapped_peek).map_err(map_format_error)?;
812-
self.write_bytes(&bytes)?;
822+
if has_trailing_attr(field) {
823+
// Trailing opaque fields stream mapped payload directly (no outer length framing),
824+
// preserving scatter-gather references for borrowed bytes.
825+
serialize_root(self, mapped_peek).map_err(map_format_error)?;
826+
} else {
827+
let mut bytes = Vec::new();
828+
let mut mapped_serializer = PostcardSerializer::new(CopyWriter::new(&mut bytes));
829+
serialize_root(&mut mapped_serializer, mapped_peek).map_err(map_format_error)?;
830+
self.write_bytes(&bytes)?;
831+
}
813832
return Ok(true);
814833
}
815834

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use facet::{Facet, FacetOpaqueAdapter, OpaqueDeserialize, OpaqueSerialize, PtrConst};
2+
use facet_postcard::{Segment, from_slice, from_slice_borrowed, to_scatter_plan, to_vec};
3+
4+
#[derive(Debug, Facet)]
5+
#[repr(u8)]
6+
#[facet(opaque = PayloadAdapter, traits(Debug))]
7+
enum Payload<'a> {
8+
Borrowed(&'a [u8]),
9+
RawBorrowed(&'a [u8]),
10+
RawOwned(Vec<u8>),
11+
}
12+
13+
struct PayloadAdapter;
14+
15+
impl FacetOpaqueAdapter for PayloadAdapter {
16+
type Error = String;
17+
type SendValue<'a> = Payload<'a>;
18+
type RecvValue<'de> = Payload<'de>;
19+
20+
fn serialize_map(value: &Self::SendValue<'_>) -> OpaqueSerialize {
21+
match value {
22+
Payload::Borrowed(bytes) => OpaqueSerialize {
23+
ptr: PtrConst::new(bytes as *const &[u8]),
24+
shape: <&[u8] as Facet>::SHAPE,
25+
},
26+
_ => unreachable!("serialize_map is only used for outgoing payload values"),
27+
}
28+
}
29+
30+
fn deserialize_build<'de>(
31+
input: OpaqueDeserialize<'de>,
32+
) -> Result<Self::RecvValue<'de>, Self::Error> {
33+
Ok(match input {
34+
OpaqueDeserialize::Borrowed(bytes) => Payload::RawBorrowed(bytes),
35+
OpaqueDeserialize::Owned(bytes) => Payload::RawOwned(bytes),
36+
})
37+
}
38+
}
39+
40+
#[derive(Debug, Facet)]
41+
struct FramedTrailing<'a> {
42+
id: u8,
43+
#[facet(trailing)]
44+
payload: Payload<'a>,
45+
}
46+
47+
fn flatten(plan: &facet_postcard::ScatterPlan<'_>) -> Vec<u8> {
48+
let mut out = vec![0u8; plan.total_size()];
49+
plan.write_into(&mut out)
50+
.expect("scatter plan should flatten");
51+
out
52+
}
53+
54+
#[test]
55+
fn issue_2108_trailing_opaque_omits_outer_length_framing() {
56+
let value = FramedTrailing {
57+
id: 7,
58+
payload: Payload::Borrowed(&[0x10, 0x20]),
59+
};
60+
61+
let bytes = to_vec(&value).expect("serialization should succeed");
62+
assert_eq!(bytes, vec![7, 2, 0x10, 0x20]);
63+
}
64+
65+
#[test]
66+
fn issue_2108_trailing_opaque_deserialize_consumes_remaining_bytes() {
67+
let bytes = vec![7, 2, 0xAB, 0xCD];
68+
69+
let decoded_borrowed: FramedTrailing<'_> =
70+
from_slice_borrowed(&bytes).expect("borrowed deserialization should succeed");
71+
match decoded_borrowed.payload {
72+
Payload::RawBorrowed(slice) => {
73+
assert_eq!(slice, &[2, 0xAB, 0xCD]);
74+
assert_eq!(slice.as_ptr(), bytes[1..].as_ptr());
75+
}
76+
other => panic!("expected RawBorrowed, got {other:?}"),
77+
}
78+
79+
let decoded_owned: FramedTrailing<'static> =
80+
from_slice(&bytes).expect("owned deserialization should succeed");
81+
match decoded_owned.payload {
82+
Payload::RawOwned(buf) => assert_eq!(buf, vec![2, 0xAB, 0xCD]),
83+
other => panic!("expected RawOwned, got {other:?}"),
84+
}
85+
}
86+
87+
#[test]
88+
fn issue_2108_trailing_opaque_preserves_scatter_gather_references() {
89+
let payload = [0x44, 0x55, 0x66];
90+
let value = FramedTrailing {
91+
id: 9,
92+
payload: Payload::Borrowed(&payload),
93+
};
94+
95+
let plan = to_scatter_plan(&value).expect("scatter plan should serialize");
96+
let expected = to_vec(&value).expect("regular serialization should succeed");
97+
98+
assert_eq!(plan.total_size(), expected.len());
99+
assert_eq!(flatten(&plan), expected);
100+
101+
let has_payload_ref = plan.segments().iter().any(|segment| match segment {
102+
Segment::Reference { bytes } => {
103+
bytes.len() == payload.len() && bytes.as_ptr() == payload.as_ptr()
104+
}
105+
Segment::Staged { .. } => false,
106+
});
107+
assert!(
108+
has_payload_ref,
109+
"expected a scatter-gather reference segment for borrowed payload bytes"
110+
);
111+
}

0 commit comments

Comments
 (0)