nros_serdes/size.rs
1//! Phase 380 W1 — a message's serialized size, computed instead of guessed.
2//!
3//! `NROS_SUBSCRIPTION_BUFFER_SIZE` defaults to 1024 bytes and is a GUESS.
4//! Nothing checks it against the messages an image actually subscribes to, so a
5//! sample that does not fit is dropped AFTER the transport ACKed it, and
6//! `report_dropped_take` can only say "raise the knob" because the runtime does
7//! not know what value would have worked. On a target that knob is static RAM
8//! nobody can spare. Issue 0776 is the gap; this module is the calculator.
9//!
10//! # Why this is not a vtable slot
11//!
12//! Settled in phase-376 W4: nothing about a size bound varies by backend, and
13//! upstream proves it by not varying at all — both `librmw_cyclonedds_cpp.so`
14//! and `librmw_fastrtps_cpp.so` answer `rmw_get_serialized_message_size` with
15//! `RMW_RET_UNSUPPORTED`, and nothing in a Humble install calls it. Upstream can
16//! afford that because its serialized buffer RESIZES; the bound is a hint that
17//! saves a realloc. Ours cannot resize, so the same number is load-bearing here.
18//!
19//! # Thread the offset — do not sum the maxima
20//!
21//! Padding is a function of WHERE a field starts, so a calculation that sums
22//! per-field maxima is wrong the moment a variable-length field shifts what
23//! follows. [`size_bound`] therefore takes `current_alignment` in and returns
24//! the size from there, which is also what makes nested structs compose with no
25//! special case — the same signature rosidl's generated Fast-RTPS support uses
26//! (`max_serialized_size_T(full_bounded, is_plain, current_alignment)`).
27//!
28//! # Agreement with the writer is the only thing that matters
29//!
30//! Every rule below was read out of `CdrWriter`, not out of the CDR spec:
31//! `align()` caps alignment at 4 under XCDR2 and honours 8 under XCDR1;
32//! `begin_dheader()` aligns to 4 and reserves 4 bytes for EVERY struct under
33//! XCDR2 (a generated `serialize` opens with it, so nested structs get one too);
34//! `write_string` writes `len + 1` and then the NUL. A bound that merely looks
35//! right is worthless — see `size_tests.rs`, which checks it against the bytes
36//! the writer actually produced.
37
38use core::ops::ControlFlow;
39
40use crate::{
41 cdr::EncodingVersion,
42 schema::{Field, FieldType},
43};
44
45/// What one walk of a schema can say about size.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct SizeBound {
48 /// Bytes, given the starting offset the walk was handed.
49 pub bytes: usize,
50 /// False once an unbounded `String` / `WString` / `Sequence` is reached —
51 /// `bytes` is then a FLOOR, not a bound, and callers must not size a buffer
52 /// from it.
53 pub bounded: bool,
54 /// No variable-length member anywhere, so the WIRE length is fixed: `bytes`
55 /// is EXACT rather than an upper bound.
56 ///
57 /// **Nothing dispatches on this, by decision** (issues 1369 and 1422). It is
58 /// the walk's exactness property, asserted as such by
59 /// `plain_struct_bound_is_exact` here and by
60 /// `bound_holds_against_the_writer_for_every_generated_type` over the whole
61 /// generated corpus, and it is surfaced as
62 /// [`crate::schema::Message::IS_PLAIN`] / [`is_loan_eligible`] so there is
63 /// ONE definition of "fixed layout" for whoever finally needs one.
64 ///
65 /// The earlier text here said phase-380 W5 "wires this to
66 /// `borrow_loaned_message` / `subscription_supports_in_place`". It does not:
67 /// W5 shipped the definition and left the wiring to whoever owns those slots
68 /// (`docs/roadmap/archived/phase-380-serialized-size-bound.md:228-232`), and
69 /// **issue 0814 step 5 is where the wiring now waits** — deliberately, since
70 /// 0814 measures the loan surface as a cost on three of four backends and
71 /// the natural consumer of this predicate is Cyclone-with-iceoryx, which no
72 /// target we ship can reach. Do not gate the in-place path on it: that path
73 /// hands the callback raw CDR to deserialize, which needs no fixed layout, so
74 /// the flag would only take it away from every type with a `String`.
75 ///
76 /// It is NOT "POD-blit eligible", and a second flag under that name in the
77 /// codegen IR was deleted by issue 1422 for disagreeing with this one on
78 /// `bool`, on nested all-`float64` structs and on mixed alignment. Nor does
79 /// it make the bound version-INDEPENDENT: XCDR2's DHEADER is fixed-width, so
80 /// a plain type stays plain while its bound still differs per encoding
81 /// (`Time` measures 12 under XCDR1 and 16 under XCDR2).
82 pub plain: bool,
83}
84
85/// The encapsulation header the payload crossing our vtable carries: 2 bytes of
86/// representation id + 2 of options.
87///
88/// TOP LEVEL ONLY, not per nested struct — which is why the Cyclone backend
89/// computes `total = paylen + 4`. [`size_bound`] deliberately does not include
90/// it; [`max_serialized_size`] does.
91pub const ENCAPSULATION_HEADER_BYTES: usize = 4;
92
93/// Padding needed to reach `alignment` from `offset`, under `version`.
94///
95/// Mirrors `CdrWriter::align` exactly, including the XCDR2 cap at 4 — a message
96/// containing an `int64` therefore has TWO different bounds, and a single
97/// constant would be silently wrong for one encoding.
98const fn pad_to(offset: usize, alignment: usize, version: EncodingVersion) -> usize {
99 let alignment = match version {
100 EncodingVersion::Xcdr2 => {
101 if alignment > 4 {
102 4
103 } else {
104 alignment
105 }
106 }
107 EncodingVersion::Xcdr1 => alignment,
108 };
109 if alignment == 0 {
110 return 0;
111 }
112 (alignment - (offset % alignment)) % alignment
113}
114
115/// Size of one field, starting at `offset`. Returns the new offset plus the two
116/// flags, folded by the caller.
117const fn field_bound(
118 ty: &FieldType,
119 version: EncodingVersion,
120 offset: usize,
121) -> (usize, bool, bool) {
122 match ty {
123 FieldType::Bool | FieldType::Uint8 | FieldType::Int8 => (offset + 1, true, true),
124 FieldType::Uint16 | FieldType::Int16 => {
125 let o = offset + pad_to(offset, 2, version);
126 (o + 2, true, true)
127 }
128 FieldType::Uint32 | FieldType::Int32 | FieldType::Float32 => {
129 let o = offset + pad_to(offset, 4, version);
130 (o + 4, true, true)
131 }
132 FieldType::Uint64 | FieldType::Int64 | FieldType::Float64 => {
133 let o = offset + pad_to(offset, 8, version);
134 (o + 8, true, true)
135 }
136 // `write_string` writes `len + 1` as the u32 prefix and then the NUL,
137 // so a bound of `n` payload bytes costs `4 + n + 1`.
138 FieldType::BoundedString(n) => {
139 let o = offset + pad_to(offset, 4, version);
140 (o + 4 + *n + 1, true, false)
141 }
142 FieldType::BoundedWString(n) => {
143 let o = offset + pad_to(offset, 4, version);
144 (o + 4 + 2 * *n, true, false)
145 }
146 // No bound exists. Account the length prefix so `bytes` is an honest
147 // floor, and clear `bounded` so nobody sizes a buffer from it.
148 FieldType::String | FieldType::WString | FieldType::Sequence(_) => {
149 let o = offset + pad_to(offset, 4, version);
150 (o + 4, false, false)
151 }
152 FieldType::Array(n, inner) => {
153 let mut o = offset;
154 let mut bounded = true;
155 let mut plain = true;
156 let mut i = 0;
157 while i < *n {
158 let (next, b, p) = field_bound(inner, version, o);
159 o = next;
160 bounded &= b;
161 plain &= p;
162 i += 1;
163 }
164 // A fixed array of plain elements is itself plain; of anything else,
165 // not — the element count is fixed but each element's size is not.
166 (o, bounded, plain)
167 }
168 FieldType::BoundedSequence(n, inner) => {
169 let mut o = offset + pad_to(offset, 4, version);
170 o += 4;
171 let mut bounded = true;
172 let mut i = 0;
173 while i < *n {
174 let (next, b, _) = field_bound(inner, version, o);
175 o = next;
176 bounded &= b;
177 i += 1;
178 }
179 // Never plain: the wire length varies with the actual element count
180 // even though its maximum is known.
181 (o, bounded, false)
182 }
183 FieldType::Nested(nested) => {
184 let inner = size_bound(nested.fields, version, offset);
185 (inner.bytes, inner.bounded, inner.plain)
186 }
187 }
188}
189
190/// Walk `fields` from `current_alignment`, returning the size bound of the
191/// struct they describe.
192///
193/// The returned `bytes` is an ABSOLUTE offset — the position after the last
194/// field, measured from the same origin `current_alignment` was measured from —
195/// so a nested struct composes by being handed the parent's current offset.
196/// Subtract the starting offset if a length is what you want.
197///
198/// Excludes the encapsulation header; see [`max_serialized_size`].
199pub const fn size_bound(
200 fields: &'static [Field],
201 version: EncodingVersion,
202 current_alignment: usize,
203) -> SizeBound {
204 let mut offset = current_alignment;
205 let mut bounded = true;
206 let mut plain = true;
207
208 // XCDR2 delimits EVERY appendable struct, nested ones included: a generated
209 // `serialize` opens with `begin_dheader()`, which aligns to 4 and reserves
210 // 4. Under XCDR1 the call is a no-op. Missing this UNDER-reports, which is
211 // the dangerous direction — an under-reported bound sizes a buffer too
212 // small and reintroduces the very drop this exists to stop.
213 if matches!(version, EncodingVersion::Xcdr2) {
214 offset += pad_to(offset, 4, version);
215 offset += 4;
216 // A DHEADER does not make a struct non-plain: its width is fixed.
217 }
218
219 let mut i = 0;
220 while i < fields.len() {
221 let (next, b, p) = field_bound(&fields[i].ty, version, offset);
222 offset = next;
223 bounded &= b;
224 plain &= p;
225 i += 1;
226 }
227
228 SizeBound {
229 bytes: offset,
230 bounded,
231 plain,
232 }
233}
234
235/// How deep a field path [`first_unbounded`] will name before it stops.
236///
237/// ROS message nesting is shallow in practice (`PoseStamped.header.stamp.sec`
238/// is three), and this walk runs on `no_std` with no allocator, so the path is
239/// a fixed array rather than a `Vec`. A deeper type still reports — the path is
240/// simply truncated, and [`UnboundedField::truncated`] says so, because a path
241/// that silently stops short would point at the wrong member.
242pub const MAX_FIELD_PATH_DEPTH: usize = 8;
243
244/// Which unbounded member kind was reached.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum UnboundedKind {
247 /// `string` with no IDL bound.
248 String,
249 /// `wstring` with no IDL bound.
250 WString,
251 /// `sequence<T>` with no IDL bound.
252 Sequence,
253}
254
255impl UnboundedKind {
256 /// The IDL spelling, for a diagnostic.
257 pub const fn as_str(self) -> &'static str {
258 match self {
259 UnboundedKind::String => "string",
260 UnboundedKind::WString => "wstring",
261 UnboundedKind::Sequence => "sequence<T>",
262 }
263 }
264}
265
266/// The first member that makes a type unbounded, named.
267///
268/// [`max_serialized_size`] answers `None`, which is honest and useless on its
269/// own: a user told their type has no bound cannot act without knowing WHICH
270/// member costs it, and the offender is routinely three structs down in a type
271/// they did not write. This is that answer.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct UnboundedField {
274 /// Field names from the root outward; only `depth` entries are meaningful.
275 pub path: [&'static str; MAX_FIELD_PATH_DEPTH],
276 /// How many entries of `path` are set.
277 pub depth: usize,
278 /// True when nesting exceeded [`MAX_FIELD_PATH_DEPTH`] and `path` names a
279 /// prefix rather than the whole route.
280 pub truncated: bool,
281 /// What kind of member it is.
282 pub kind: UnboundedKind,
283}
284
285impl core::fmt::Display for UnboundedField {
286 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287 for (i, seg) in self.path.iter().take(self.depth).enumerate() {
288 if i > 0 {
289 f.write_str(".")?;
290 }
291 f.write_str(seg)?;
292 }
293 if self.truncated {
294 f.write_str(".…")?;
295 }
296 write!(f, " ({})", self.kind.as_str())
297 }
298}
299
300/// Report EVERY member that makes `fields` unbounded, in declaration order.
301///
302/// [`first_unbounded`] answers "which member costs the bound" one member at a
303/// time, and that is the wrong shape for the caller that actually asks.
304/// phase-403 W0 made an unbounded type a BUILD ERROR, and a stock ROS type is
305/// routinely unbounded in several places at once — `nav_msgs/Odometry` has
306/// `header.frame_id` and `child_frame_id`, and `sensor_msgs/PointCloud2` has
307/// four. Naming only the first turns "bound your types" into cap, rebuild,
308/// discover the next one, repeat, once per member, with a whole codegen run
309/// between each step. One build should name everything that needs a bound.
310///
311/// THE walk. [`first_unbounded`] is expressed on top of this rather than beside
312/// it: two walks of one schema is the shape the sizes-header mirror defect keeps
313/// taking (issues 0088 -> 0268), and "the first thing this reports" is exactly
314/// what "the first unbounded member" means, so there is nothing left for a
315/// second implementation to say.
316///
317/// `visit` returns [`ControlFlow`] so a caller that wants only the first can
318/// stop the walk rather than let it enumerate a whole type and discard all but
319/// one answer. The return value is `Break` iff the visitor broke.
320///
321/// `&mut dyn FnMut` rather than `impl FnMut`: the walk recurses, and a recursive
322/// generic function cannot name the closure type it would instantiate itself
323/// with. `no_std` and allocation-free either way — nothing is collected here,
324/// which is what lets an all-members form exist at all on a target with no
325/// allocator. The COLLECTING is the caller's, and only codegen (`std`) does it.
326pub fn visit_unbounded(
327 fields: &'static [Field],
328 visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
329) -> ControlFlow<()> {
330 fn walk(
331 fields: &'static [Field],
332 prefix: &mut [&'static str; MAX_FIELD_PATH_DEPTH],
333 depth: usize,
334 visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
335 ) -> ControlFlow<()> {
336 for field in fields {
337 let truncated = depth >= MAX_FIELD_PATH_DEPTH;
338 if !truncated {
339 prefix[depth] = field.name;
340 }
341 let here = |kind: UnboundedKind| UnboundedField {
342 path: *prefix,
343 depth: if truncated {
344 MAX_FIELD_PATH_DEPTH
345 } else {
346 depth + 1
347 },
348 truncated,
349 kind,
350 };
351 match &field.ty {
352 FieldType::String => visit(here(UnboundedKind::String))?,
353 FieldType::WString => visit(here(UnboundedKind::WString))?,
354 FieldType::Sequence(_) => visit(here(UnboundedKind::Sequence))?,
355 // A fixed array or a bounded sequence is bounded only if its
356 // ELEMENT is, and the element can itself be an unbounded
357 // string — `string[4]` has no bound. The element carries no
358 // name of its own, so it reports at the field's own path, and
359 // ONCE: several offenders inside one unnamed element would all
360 // print the same path, which reads as a repeated line rather
361 // than as more information.
362 FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
363 if let Some(kind) = element_unbounded(inner) {
364 visit(here(kind))?;
365 }
366 }
367 FieldType::Nested(nested) => {
368 if truncated {
369 // Cannot record another segment; report the deepest
370 // path we can name rather than descending silently.
371 walk(nested.fields, prefix, depth, &mut |mut u| {
372 u.truncated = true;
373 visit(u)
374 })?;
375 } else {
376 walk(nested.fields, prefix, depth + 1, visit)?;
377 }
378 }
379 _ => {}
380 }
381 }
382 ControlFlow::Continue(())
383 }
384
385 /// An element type is not a field, so it has no name — report only its kind.
386 fn element_unbounded(ty: &'static FieldType) -> Option<UnboundedKind> {
387 match ty {
388 FieldType::String => Some(UnboundedKind::String),
389 FieldType::WString => Some(UnboundedKind::WString),
390 FieldType::Sequence(_) => Some(UnboundedKind::Sequence),
391 FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
392 element_unbounded(inner)
393 }
394 FieldType::Nested(nested) => {
395 // A nested struct inside an array: any unbounded member counts.
396 first_unbounded(nested.fields).map(|u| u.kind)
397 }
398 _ => None,
399 }
400 }
401
402 let mut prefix = [""; MAX_FIELD_PATH_DEPTH];
403 walk(fields, &mut prefix, 0, visit)
404}
405
406/// Find the first member that makes `fields` unbounded, if any.
407///
408/// Returns `None` exactly when [`size_bound`] reports `bounded` — the two walk
409/// the same schema by the same rules, so a type that has a bound has no
410/// offender to name and vice versa. That agreement is asserted by test, not
411/// assumed: two walks of one schema is the shape the sizes-header mirror defect
412/// keeps taking (issues 0088 -> 0268), so the second one exists only because it
413/// answers a question the first cannot (WHICH member) and must be checked
414/// against it.
415///
416/// The walk is [`visit_unbounded`]; this is its "stop at the first one" caller.
417/// Reach for [`visit_unbounded`] when the diagnostic should name every member a
418/// user has to fix, which is what codegen wants now that an unbounded type is a
419/// build error (phase-403 W0).
420///
421/// Not `const`: it recurses through `&'static NestedType`, and the array
422/// bookkeeping is not worth expressing in a const walk when every caller is a
423/// diagnostic path.
424pub fn first_unbounded(fields: &'static [Field]) -> Option<UnboundedField> {
425 let mut found = None;
426 let _ = visit_unbounded(fields, &mut |u| {
427 found = Some(u);
428 ControlFlow::Break(())
429 });
430 found
431}
432
433/// The whole payload a publisher hands the transport: encapsulation header plus
434/// the struct's body, starting from offset 0.
435///
436/// `None` when the type is unbounded — the honest answer, and the one that keeps
437/// a caller from sizing a buffer off a floor. Reach for `serialized_size(&self)`
438/// (phase-380 W3) when an unbounded type still needs a number for THIS message.
439pub const fn max_serialized_size(
440 fields: &'static [Field],
441 version: EncodingVersion,
442) -> Option<usize> {
443 // The body's offsets are measured from after the encapsulation header —
444 // that is where `CdrWriter`'s `origin` sits — so the walk starts at 0 and
445 // the header is added once, at the top.
446 let bound = size_bound(fields, version, 0);
447 if bound.bounded {
448 Some(ENCAPSULATION_HEADER_BYTES + bound.bytes)
449 } else {
450 None
451 }
452}
453
454/// Phase 380 W3 — the EXACT serialized size of THIS message.
455///
456/// Two questions get asked about size and they are not the same one:
457///
458/// | question | asked by | this module |
459/// | --- | --- | --- |
460/// | how large can this TYPE ever be? | build-time buffer sizing | [`max_serialized_size`] |
461/// | how large is THIS message? | a publisher before publishing; a drop report | `serialized_size` |
462///
463/// The second is the only honest answer for an unbounded type, where the first
464/// is `None` — a `String` field has no maximum, but the string in hand has a
465/// length. It is what lets a drop report name the number that would have worked
466/// instead of saying "raise the knob".
467///
468/// Exact by construction: it runs the REAL writer with its stores disabled
469/// (`CdrWriter::measuring`), so the count comes from the same code that emits
470/// the bytes. A second walk of the schema could not see the actual string
471/// lengths and sequence counts, and would be a second implementation to keep in
472/// step besides.
473///
474/// Includes the encapsulation header, matching [`max_serialized_size`], so the
475/// two are directly comparable — which is the whole point at a call site
476/// deciding whether a message fits.
477pub fn serialized_size<T: crate::traits::Serialize>(
478 value: &T,
479 version: EncodingVersion,
480) -> Result<usize, crate::error::SerError> {
481 let mut w = crate::cdr::CdrWriter::measuring(&mut [], version);
482 value.serialize(&mut w)?;
483 Ok(ENCAPSULATION_HEADER_BYTES + w.position())
484}
485
486/// Phase 380 W4 — does a receive buffer of `rx_buf` bytes fit every message of
487/// this type?
488///
489/// `true` when the type is bounded and the bound fits. **`false` when the type
490/// is UNBOUNDED**, deliberately: no finite buffer fits a `String`, so the
491/// honest answer to "is this guaranteed to fit" is no. A caller that wants
492/// "fits unless proven otherwise" is asking a different question and should
493/// look at [`serialized_size`] per message.
494///
495/// Const, so a call site can put it in a `const { assert!(...) }` and turn a
496/// runtime drop into a build error:
497///
498/// ```ignore
499/// const { assert!(buffer_fits::<Odometry>(RX_BUF, EncodingVersion::Xcdr1)) };
500/// ```
501///
502/// # Why this is not simply a bound on `Subscription`
503///
504/// `Subscription<M, RX_BUF>` bounds `M: RosMessage`, which is a DIFFERENT trait
505/// from [`crate::schema::Message`] — and measured 2026-08-26, hand-written
506/// `RosMessage` types with no schema DO exist (`nros-core/src/service.rs`, the
507/// component-runtime tests). Tightening that bound would break them, so the
508/// assertion is opt-in at sites where the schema is known rather than universal
509/// at a site where it is not. See the phase doc for what closing that gap needs.
510pub const fn buffer_fits<M: crate::schema::Message>(
511 rx_buf: usize,
512 version: EncodingVersion,
513) -> bool {
514 let bound = match version {
515 EncodingVersion::Xcdr1 => M::MAX_SERIALIZED_SIZE_XCDR1,
516 EncodingVersion::Xcdr2 => M::MAX_SERIALIZED_SIZE_XCDR2,
517 };
518 match bound {
519 Some(n) => rx_buf >= n,
520 None => false,
521 }
522}
523
524/// Phase 392 W3a — the type's own receive-buffer bound, or `None` when it has
525/// none.
526///
527/// The VALUE behind [`bound_fits`]'s predicate. A subscription that knows its
528/// type's bound can be routed to a size class instead of forcing the GLOBAL
529/// buffer knob up: `NROS_SUBSCRIBER_BUFFER_SIZE` multiplies across
530/// `MAX_SUBSCRIBERS x RING_DEPTH`, so raising it from 1024 to 4096 for one
531/// 4 KiB topic costs 98,304 bytes, while the large class that topic belongs in
532/// is already reserved and empty.
533///
534/// Takes the larger of the two encodings, for the same reason `bound_fits`
535/// does: the peer picks the encoding at runtime, so sizing from XCDR1 alone is
536/// a trap.
537///
538/// `None` means "no bound EXISTS", never "unknown" — phase 380 is explicit that
539/// a buffer must not be sized from a fallback. A caller routing by size class
540/// must treat `None` as "keep the default", not as "assume small".
541pub const fn max_serialized_bound<M: crate::schema::Message>() -> Option<usize> {
542 match (M::MAX_SERIALIZED_SIZE_XCDR1, M::MAX_SERIALIZED_SIZE_XCDR2) {
543 (Some(x1), Some(x2)) => Some(if x1 > x2 { x1 } else { x2 }),
544 // One encoding unbounded makes the type unbounded on the wire, because
545 // the peer chooses. Not `Some(the_other)`.
546 _ => None,
547 }
548}
549
550/// Phase 380 W4 — the BUILD-ASSERTION predicate: `false` only when the type is
551/// provably too large for `rx_buf`.
552///
553/// Distinct from [`buffer_fits`], and the difference is the whole reason both
554/// exist:
555///
556/// * `buffer_fits` answers "is this GUARANTEED to fit", so an unbounded type is
557/// `false` — no finite buffer fits a `String`.
558/// * this answers "can we PROVE it will not fit", so an unbounded type is
559/// `true` — there is nothing to check, and failing the build for every
560/// `std_msgs/String` subscription would be absurd.
561///
562/// Asserting `buffer_fits` at a subscription site was my first attempt and
563/// would have refused the most common message in ROS. Kept as two named
564/// functions rather than one with a flag, because the wrong one is silently
565/// plausible at either call site.
566///
567/// Checks BOTH encodings and takes the larger: the encoding is a runtime
568/// property of the peer, so a buffer sized from XCDR1 alone is a trap — XCDR2
569/// adds a DHEADER per struct and is the bigger of the two for nested types.
570pub const fn bound_fits<M: crate::schema::Message>(rx_buf: usize) -> bool {
571 let x1 = match M::MAX_SERIALIZED_SIZE_XCDR1 {
572 Some(n) => n,
573 None => return true, // unbounded: nothing to prove
574 };
575 let x2 = match M::MAX_SERIALIZED_SIZE_XCDR2 {
576 Some(n) => n,
577 None => return true,
578 };
579 let largest = if x1 > x2 { x1 } else { x2 };
580 rx_buf >= largest
581}
582
583/// Phase 380 W5 — is this type loan-eligible?
584///
585/// A loan hands out a pointer into the transport's own memory, which is sound
586/// only when the layout is fixed: no length prefix to chase, no variable
587/// member. That is exactly [`SizeBound::plain`], so the answer falls out of W1
588/// rather than becoming a second notion of "fixed layout" maintained by hand —
589/// which is the drift `borrow_loaned_message` and
590/// `subscription_supports_in_place` would otherwise each grow their own version
591/// of.
592///
593/// **It has no non-test caller, and that is the decision rather than an
594/// oversight** (issue 1422; the same finding as issue 1369). Its natural
595/// consumer is a TYPED loan, and issue 0814 recommends not building one now:
596/// the byte-span loan surface it would sit beside is measured as strictly worse
597/// than `publish_raw` on three of four backends, and the one place `IS_PLAIN`
598/// matches a real gate is Cyclone's own `fixed_size` check, which loans only
599/// under iceoryx. **0814 step 5 is the consumer**, if and when it exists. The
600/// function is kept — not inlined into that future caller — because its whole
601/// job is to be the one place the predicate is spelled; two spellings is the
602/// defect issue 1422 deleted from the codegen IR.
603pub const fn is_loan_eligible<M: crate::schema::Message>() -> bool {
604 M::IS_PLAIN
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use crate::{
611 cdr::CdrWriter,
612 schema::{Field, NestedType},
613 };
614
615 const fn f(name: &'static str, ty: FieldType) -> Field {
616 Field {
617 name,
618 ty,
619 offset: 0,
620 }
621 }
622
623 // ========================================================================
624 // issue 0896 layer 0 — naming the member that costs the bound
625 // ========================================================================
626
627 /// The two walks must agree. `first_unbounded` is a SECOND walk of the same
628 /// schema, which is the shape the sizes-header mirror defect keeps taking
629 /// (0088 -> 0268), so it earns its existence only by answering a question
630 /// `size_bound` cannot — and only while it never disagrees about the
631 /// question they share.
632 fn agree(fields: &'static [Field]) {
633 for version in [EncodingVersion::Xcdr1, EncodingVersion::Xcdr2] {
634 let bounded = size_bound(fields, version, 0).bounded;
635 let named = first_unbounded(fields);
636 assert_eq!(
637 bounded,
638 named.is_none(),
639 "size_bound says bounded={bounded} but first_unbounded says \
640 {named:?} ({version:?})"
641 );
642 }
643 }
644
645 #[test]
646 fn a_bounded_type_has_no_offender_to_name() {
647 static FIELDS: &[Field] = &[
648 f("x", FieldType::Uint32),
649 f("s", FieldType::BoundedString(8)),
650 ];
651 agree(FIELDS);
652 }
653
654 #[test]
655 fn an_unbounded_string_is_named() {
656 static FIELDS: &[Field] = &[f("flag", FieldType::Bool), f("label", FieldType::String)];
657 agree(FIELDS);
658 let u = first_unbounded(FIELDS).unwrap();
659 assert_eq!(u.kind, UnboundedKind::String);
660 assert_eq!(&u.path[..u.depth], &["label"]);
661 }
662
663 /// The offender is usually not at the top level — this is the whole reason
664 /// the path exists rather than a bare field name.
665 #[test]
666 fn a_nested_offender_reports_its_full_path() {
667 static INNER: &[Field] = &[f("sec", FieldType::Int32), f("frame_id", FieldType::String)];
668 static INNER_TY: NestedType = NestedType {
669 type_name: "std_msgs/msg/Header",
670 fields: INNER,
671 };
672 static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
673 agree(FIELDS);
674 let u = first_unbounded(FIELDS).unwrap();
675 assert_eq!(&u.path[..u.depth], &["header", "frame_id"]);
676 }
677
678 /// `string[4]` is a FIXED array of an UNBOUNDED element: the count is
679 /// known and the bound is not. Reported at the field's own path, because
680 /// an element has no name of its own.
681 #[test]
682 fn a_fixed_array_of_unbounded_elements_is_unbounded() {
683 static ELEM: FieldType = FieldType::String;
684 static FIELDS: &[Field] = &[f("names", FieldType::Array(4, &ELEM))];
685 agree(FIELDS);
686 let u = first_unbounded(FIELDS).unwrap();
687 assert_eq!(&u.path[..u.depth], &["names"]);
688 assert_eq!(u.kind, UnboundedKind::String);
689 }
690
691 /// A bounded sequence of bounded elements IS bounded — the easy way to get
692 /// this wrong is to treat any sequence as unbounded.
693 #[test]
694 fn a_bounded_sequence_of_bounded_elements_is_bounded() {
695 static ELEM: FieldType = FieldType::Uint16;
696 static FIELDS: &[Field] = &[f("ranges", FieldType::BoundedSequence(16, &ELEM))];
697 agree(FIELDS);
698 assert!(first_unbounded(FIELDS).is_none());
699 }
700
701 #[test]
702 fn the_first_offender_wins_so_the_message_names_one_thing() {
703 static FIELDS: &[Field] = &[
704 f("a", FieldType::String),
705 f("b", FieldType::Sequence(&FieldType::Uint8)),
706 ];
707 let u = first_unbounded(FIELDS).unwrap();
708 assert_eq!(&u.path[..u.depth], &["a"]);
709 }
710
711 /// A stack-only `core::fmt::Write` sink.
712 ///
713 /// `alloc::format!` would be shorter and would test the wrong build:
714 /// `Display` here exists FOR the `no_std` diagnostic path, so the test runs
715 /// where that path runs. Silently drops overflow — the assertion below
716 /// catches a truncated result either way.
717 struct Buf {
718 bytes: [u8; 128],
719 len: usize,
720 }
721
722 impl core::fmt::Write for Buf {
723 fn write_str(&mut self, s: &str) -> core::fmt::Result {
724 for b in s.as_bytes() {
725 if self.len < self.bytes.len() {
726 self.bytes[self.len] = *b;
727 self.len += 1;
728 }
729 }
730 Ok(())
731 }
732 }
733
734 #[test]
735 fn the_display_form_reads_as_a_path_and_a_kind() {
736 use core::fmt::Write;
737 static INNER: &[Field] = &[f("frame_id", FieldType::String)];
738 static INNER_TY: NestedType = NestedType {
739 type_name: "std_msgs/msg/Header",
740 fields: INNER,
741 };
742 static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
743 let u = first_unbounded(FIELDS).unwrap();
744
745 let mut buf = Buf {
746 bytes: [0; 128],
747 len: 0,
748 };
749 write!(buf, "{u}").unwrap();
750 assert_eq!(
751 core::str::from_utf8(&buf.bytes[..buf.len]).unwrap(),
752 "header.frame_id (string)"
753 );
754 }
755
756 // ========================================================================
757 // phase-403 W0 — naming EVERY member, not the first
758 // ========================================================================
759
760 /// Collect a whole type's offenders. `heapless` rather than `Vec` because
761 /// this crate is `no_std`; that is also the point of the visitor shape.
762 fn all(fields: &'static [Field]) -> heapless::Vec<UnboundedField, 16> {
763 let mut out = heapless::Vec::new();
764 let _ = visit_unbounded(fields, &mut |u| {
765 let _ = out.push(u);
766 ControlFlow::Continue(())
767 });
768 out
769 }
770
771 fn names(fields: &'static [Field]) -> heapless::Vec<&'static str, 16> {
772 let mut out = heapless::Vec::new();
773 for u in all(fields) {
774 let _ = out.push(u.path[u.depth - 1]);
775 }
776 out
777 }
778
779 /// An unbounded type is a build error now, and a stock ROS type is unbounded
780 /// in several places at once, so one build has to name all of them —
781 /// otherwise bounding a package is one cap and one full codegen run per
782 /// member.
783 #[test]
784 fn every_unbounded_member_is_visited_in_declaration_order() {
785 static FIELDS: &[Field] = &[
786 f("a", FieldType::String),
787 f("keep", FieldType::Int32),
788 f("b", FieldType::Sequence(&FieldType::Int64)),
789 f("c", FieldType::WString),
790 ];
791 assert_eq!(names(FIELDS).as_slice(), &["a", "b", "c"]);
792 }
793
794 /// Nested members are visited too, and a bounded sibling does not stop the
795 /// walk from continuing past the struct that contained one.
796 #[test]
797 fn the_walk_continues_past_a_nested_offender_to_its_siblings() {
798 static INNER: &[Field] = &[f("frame_id", FieldType::String)];
799 static INNER_TY: NestedType = NestedType {
800 type_name: "std_msgs/msg/Header",
801 fields: INNER,
802 };
803 static FIELDS: &[Field] = &[
804 f("header", FieldType::Nested(&INNER_TY)),
805 f("child_frame_id", FieldType::String),
806 ];
807 assert_eq!(names(FIELDS).as_slice(), &["frame_id", "child_frame_id"]);
808 // The path, not just the leaf name, so a diagnostic can be acted on.
809 assert_eq!(all(FIELDS)[0].path[0], "header");
810 assert_eq!(all(FIELDS)[0].depth, 2);
811 }
812
813 /// `first_unbounded` is now expressed on top of this walk, so it must still
814 /// answer exactly what the walk reports first — and must still stop, rather
815 /// than enumerating a type and discarding all but one answer.
816 #[test]
817 fn the_first_offender_is_the_first_one_visited() {
818 static INNER: &[Field] = &[f("frame_id", FieldType::String)];
819 static INNER_TY: NestedType = NestedType {
820 type_name: "std_msgs/msg/Header",
821 fields: INNER,
822 };
823 static FLAT: &[Field] = &[f("a", FieldType::String), f("b", FieldType::String)];
824 static NESTED: &[Field] = &[
825 f("header", FieldType::Nested(&INNER_TY)),
826 f("tail", FieldType::String),
827 ];
828 static CLEAN: &[Field] = &[f("only", FieldType::Int32)];
829 for fields in [FLAT, NESTED, CLEAN] {
830 assert_eq!(first_unbounded(fields), all(fields).first().copied());
831 }
832 }
833
834 /// A visitor that breaks stops the walk where it broke — which is the
835 /// mechanism `first_unbounded` uses, asserted rather than assumed.
836 #[test]
837 fn a_visitor_that_breaks_stops_the_walk() {
838 static FIELDS: &[Field] = &[
839 f("a", FieldType::String),
840 f("b", FieldType::String),
841 f("c", FieldType::String),
842 ];
843 let mut seen = 0usize;
844 let flow = visit_unbounded(FIELDS, &mut |_| {
845 seen += 1;
846 if seen == 2 {
847 ControlFlow::Break(())
848 } else {
849 ControlFlow::Continue(())
850 }
851 });
852 assert_eq!(seen, 2);
853 assert_eq!(flow, ControlFlow::Break(()));
854 }
855
856 /// A bounded type has nothing to report, from either form.
857 #[test]
858 fn a_bounded_type_visits_nothing() {
859 static FIELDS: &[Field] = &[
860 f("a", FieldType::BoundedString(8)),
861 f("b", FieldType::Int32),
862 ];
863 assert!(all(FIELDS).is_empty());
864 assert!(first_unbounded(FIELDS).is_none());
865 }
866
867 /// Serialize a MAXIMAL instance of a schema with the real `CdrWriter`, and
868 /// return the byte length the writer produced (header included).
869 ///
870 /// This is the whole point of the test module: a bound that is merely
871 /// self-consistent proves nothing. Every assertion below compares
872 /// [`max_serialized_size`] against bytes this function actually wrote.
873 fn write_maximal(fields: &[Field], version: EncodingVersion, buf: &mut [u8]) -> usize {
874 let mut w = match version {
875 EncodingVersion::Xcdr1 => CdrWriter::new_with_header(buf).unwrap(),
876 EncodingVersion::Xcdr2 => CdrWriter::new_with_header_xcdr2(buf).unwrap(),
877 };
878 let dh = w.begin_dheader().unwrap();
879 write_fields(&mut w, fields);
880 w.end_dheader(dh).unwrap();
881 w.position()
882 }
883
884 /// Phase 392 W3a — `max_serialized_bound` is what routes a subscription to a
885 /// size class, so the two ways it can be wrong both cost real RAM or real
886 /// truncation: reporting the smaller encoding under-sizes the block the peer
887 /// may actually fill, and inventing a number for an unbounded type sizes a
888 /// buffer from a fallback, which phase 380 forbids in as many words.
889 struct BoundedMsg;
890 impl crate::schema::Message for BoundedMsg {
891 const TYPE_NAME: &'static str = "test/msg/BoundedMsg";
892 const FIELDS: &'static [Field] = &[
893 f("a", FieldType::Uint8),
894 f("b", FieldType::Uint64),
895 f("c", FieldType::Uint8),
896 ];
897 }
898
899 struct UnboundedMsg;
900 impl crate::schema::Message for UnboundedMsg {
901 const TYPE_NAME: &'static str = "test/msg/UnboundedMsg";
902 const FIELDS: &'static [Field] = &[f("s", FieldType::String)];
903 }
904
905 #[test]
906 fn max_serialized_bound_takes_the_larger_encoding() {
907 let x1 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR1
908 .expect("bounded in xcdr1");
909 let x2 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR2
910 .expect("bounded in xcdr2");
911 let got = max_serialized_bound::<BoundedMsg>().expect("bounded type has a bound");
912 assert_eq!(
913 got,
914 x1.max(x2),
915 "must take the LARGER encoding — the peer picks it at runtime, so \
916 sizing from one alone is the trap phase 380 documents"
917 );
918 assert!(
919 bound_fits::<BoundedMsg>(got),
920 "the value must satisfy the predicate it is derived from"
921 );
922 assert!(
923 !bound_fits::<BoundedMsg>(got - 1),
924 "one byte under the bound must NOT fit, or the value is not tight"
925 );
926 }
927
928 #[test]
929 fn max_serialized_bound_is_none_for_an_unbounded_type() {
930 assert_eq!(
931 max_serialized_bound::<UnboundedMsg>(),
932 None,
933 "an unbounded type has NO bound; returning a number here would let a \
934 caller size a buffer from a fallback"
935 );
936 assert!(
937 bound_fits::<UnboundedMsg>(1),
938 "unbounded still passes the BUILD assertion — nothing is provable"
939 );
940 }
941
942 fn write_fields(w: &mut CdrWriter<'_>, fields: &[Field]) {
943 for field in fields {
944 write_one(w, &field.ty);
945 }
946 }
947
948 fn write_one(w: &mut CdrWriter<'_>, ty: &FieldType) {
949 match ty {
950 FieldType::Bool => w.write_bool(true).unwrap(),
951 FieldType::Uint8 => w.write_u8(0xAB).unwrap(),
952 FieldType::Int8 => w.write_i8(-1).unwrap(),
953 FieldType::Uint16 => w.write_u16(0xBEEF).unwrap(),
954 FieldType::Int16 => w.write_i16(-2).unwrap(),
955 FieldType::Uint32 => w.write_u32(0xDEADBEEF).unwrap(),
956 FieldType::Int32 => w.write_i32(-3).unwrap(),
957 FieldType::Float32 => w.write_f32(1.5).unwrap(),
958 FieldType::Uint64 => w.write_u64(u64::MAX).unwrap(),
959 FieldType::Int64 => w.write_i64(-4).unwrap(),
960 FieldType::Float64 => w.write_f64(2.5).unwrap(),
961 // Maximal = exactly `n` payload bytes, which is what the bound
962 // claims room for.
963 FieldType::BoundedString(n) => {
964 let s = "x".repeat(*n);
965 w.write_string(&s).unwrap()
966 }
967 FieldType::String => w.write_string("").unwrap(),
968 FieldType::Array(n, inner) => {
969 for _ in 0..*n {
970 write_one(w, inner);
971 }
972 }
973 FieldType::BoundedSequence(n, inner) => {
974 w.write_u32(*n as u32).unwrap();
975 for _ in 0..*n {
976 write_one(w, inner);
977 }
978 }
979 FieldType::Nested(nested) => {
980 let dh = w.begin_dheader().unwrap();
981 write_fields(w, nested.fields);
982 w.end_dheader(dh).unwrap();
983 }
984 other => panic!("test writer has no maximal value for {other:?}"),
985 }
986 }
987
988 /// The core property, in both encodings: the computed bound is an upper
989 /// bound on what the writer produces, and an EXACT one when `plain`.
990 fn assert_agrees(fields: &'static [Field], version: EncodingVersion) {
991 let mut buf = [0u8; 4096];
992 let actual = write_maximal(fields, version, &mut buf);
993 let bound = max_serialized_size(fields, version).expect("these fixtures are all bounded");
994 assert!(
995 actual <= bound,
996 "{version:?}: writer produced {actual} bytes, bound claimed {bound} — \
997 an UNDER-reported bound sizes a buffer too small, which is the drop \
998 this module exists to stop"
999 );
1000 if size_bound(fields, version, 0).plain {
1001 assert_eq!(
1002 actual, bound,
1003 "{version:?}: a plain type's bound must be EXACT, not merely an \
1004 upper bound"
1005 );
1006 }
1007 }
1008
1009 fn both(fields: &'static [Field]) {
1010 assert_agrees(fields, EncodingVersion::Xcdr1);
1011 assert_agrees(fields, EncodingVersion::Xcdr2);
1012 }
1013
1014 // `builtin_interfaces/Time` — the plain case, and the one whose bound must
1015 // be exact.
1016 static TIME: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
1017
1018 #[test]
1019 fn plain_struct_bound_is_exact() {
1020 both(TIME);
1021 assert!(size_bound(TIME, EncodingVersion::Xcdr1, 0).plain);
1022 }
1023
1024 /// Issue 1422 — the FLAG is version-independent; the bound it qualifies is
1025 /// NOT, and conflating the two is how "plain" gets read as "one number
1026 /// serves both encodings".
1027 ///
1028 /// `plain` answers "is there a variable-length member", which no encoding
1029 /// changes, so [`crate::schema::Message::IS_PLAIN`] is one const rather than
1030 /// two. XCDR2 still adds a fixed-width DHEADER per struct, so the exact size
1031 /// differs — and both of those facts have to hold for `plain` to mean
1032 /// "`bytes` is exact" rather than "`bytes` is the same everywhere". Written
1033 /// as a test because the issue that asked for a consumer for this flag
1034 /// described its one pinning assertion as version-INDEPENDENCE of the bound,
1035 /// which is not a property this walk has.
1036 #[test]
1037 fn plainness_is_version_independent_and_the_exact_size_is_not() {
1038 let x1 = size_bound(TIME, EncodingVersion::Xcdr1, 0);
1039 let x2 = size_bound(TIME, EncodingVersion::Xcdr2, 0);
1040 assert_eq!(
1041 x1.plain, x2.plain,
1042 "plainness cannot depend on the encoding"
1043 );
1044 assert!(x1.plain);
1045 assert_eq!(x1.bytes, 8, "two 4-byte scalars");
1046 assert_eq!(x2.bytes, 12, "same, plus the 4-byte DHEADER");
1047 assert_ne!(
1048 x1.bytes, x2.bytes,
1049 "a plain type still has two bounds — one constant would be wrong \
1050 for one encoding (issue 0776)"
1051 );
1052 }
1053
1054 /// The defect issue 0776 calls out first: a message containing an `int64`
1055 /// has TWO different bounds, because `CdrWriter::align` honours 8 under
1056 /// XCDR1 and caps at 4 under XCDR2. A single constant is silently wrong for
1057 /// one of them.
1058 #[test]
1059 fn eight_byte_alignment_differs_by_encoding() {
1060 // Two `int64`s, deliberately: with only ONE the two encodings come out
1061 // EQUAL, and that is a coincidence rather than a missing cap. For
1062 // `[u8, i64]` XCDR1 pads 7 before the i64 while XCDR2 caps alignment at
1063 // 4 and pads 3 — saving exactly the 4 bytes its DHEADER costs, so both
1064 // totals are 20. A test built on that single field would have "passed"
1065 // while asserting nothing, and would keep passing if the cap were
1066 // deleted. Repeating the pattern breaks the tie: the padding saving
1067 // scales with the number of 8-byte members, the DHEADER does not.
1068 static S: &[Field] = &[
1069 f("flag", FieldType::Uint8),
1070 f("big", FieldType::Int64),
1071 f("flag2", FieldType::Uint8),
1072 f("big2", FieldType::Int64),
1073 ];
1074 both(S);
1075 let x1 = max_serialized_size(S, EncodingVersion::Xcdr1).unwrap();
1076 let x2 = max_serialized_size(S, EncodingVersion::Xcdr2).unwrap();
1077 assert_ne!(
1078 x1, x2,
1079 "8-byte primitives must pad differently under the two encodings; if \
1080 these agree the alignment cap is not being applied"
1081 );
1082 assert!(
1083 x1 > x2,
1084 "XCDR1 aligns 8-byte primitives to 8 and so must be the larger of \
1085 the two here ({x1} vs {x2})"
1086 );
1087 }
1088
1089 /// The coincidence above, pinned so nobody "simplifies" the test back into
1090 /// it: for this one layout the encodings genuinely agree, and a bound that
1091 /// reported one number for both would look correct here and nowhere else.
1092 #[test]
1093 fn a_single_int64_makes_the_two_encodings_agree_by_coincidence() {
1094 static S: &[Field] = &[f("flag", FieldType::Uint8), f("big", FieldType::Int64)];
1095 both(S);
1096 assert_eq!(
1097 max_serialized_size(S, EncodingVersion::Xcdr1),
1098 max_serialized_size(S, EncodingVersion::Xcdr2),
1099 "XCDR2's DHEADER (+4) exactly offsets the padding its alignment cap \
1100 saves (-4) for this shape"
1101 );
1102 }
1103
1104 /// Padding depends on WHERE a field starts, so summing per-field maxima is
1105 /// wrong. Same fields, different order, different size.
1106 #[test]
1107 fn offset_is_threaded_not_summed() {
1108 static A: &[Field] = &[f("a", FieldType::Uint8), f("b", FieldType::Uint32)];
1109 static B: &[Field] = &[f("b", FieldType::Uint32), f("a", FieldType::Uint8)];
1110 both(A);
1111 both(B);
1112 assert_ne!(
1113 max_serialized_size(A, EncodingVersion::Xcdr1),
1114 max_serialized_size(B, EncodingVersion::Xcdr1),
1115 "u8,u32 pads and u32,u8 does not — a sum of maxima cannot tell them apart"
1116 );
1117 }
1118
1119 #[test]
1120 fn bounded_string_and_sequence_are_bounded_but_not_plain() {
1121 static S: &[Field] = &[
1122 f("name", FieldType::BoundedString(8)),
1123 f("vals", FieldType::BoundedSequence(3, &FieldType::Uint32)),
1124 ];
1125 both(S);
1126 let b = size_bound(S, EncodingVersion::Xcdr1, 0);
1127 assert!(b.bounded);
1128 assert!(!b.plain, "variable-length members are not loan-eligible");
1129 }
1130
1131 #[test]
1132 fn nested_struct_composes_at_the_parent_offset() {
1133 static NESTED: NestedType = NestedType {
1134 type_name: "builtin_interfaces/msg/Time",
1135 fields: TIME,
1136 };
1137 static S: &[Field] = &[
1138 f("flag", FieldType::Uint8),
1139 f("stamp", FieldType::Nested(&NESTED)),
1140 ];
1141 both(S);
1142 }
1143
1144 /// An unbounded member makes the type unbounded, and `max_serialized_size`
1145 /// must answer `None` rather than a floor someone could size a buffer from.
1146 #[test]
1147 fn unbounded_member_yields_none() {
1148 static S: &[Field] = &[
1149 f(
1150 "stamp",
1151 FieldType::Nested(&NestedType {
1152 type_name: "builtin_interfaces/msg/Time",
1153 fields: TIME,
1154 }),
1155 ),
1156 f("frame_id", FieldType::String),
1157 ];
1158 assert_eq!(max_serialized_size(S, EncodingVersion::Xcdr1), None);
1159 assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).bounded);
1160 assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).plain);
1161 }
1162
1163 /// XCDR2 delimits every struct, nested ones included. Missing the nested
1164 /// DHEADER under-reports — the dangerous direction.
1165 #[test]
1166 fn xcdr2_counts_a_dheader_per_struct() {
1167 static INNER: NestedType = NestedType {
1168 type_name: "t/Inner",
1169 fields: TIME,
1170 };
1171 static FLAT: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
1172 static WRAPPED: &[Field] = &[f("inner", FieldType::Nested(&INNER))];
1173 both(WRAPPED);
1174 let flat = max_serialized_size(FLAT, EncodingVersion::Xcdr2).unwrap();
1175 let wrapped = max_serialized_size(WRAPPED, EncodingVersion::Xcdr2).unwrap();
1176 assert_eq!(
1177 wrapped,
1178 flat + 4,
1179 "the nested struct's own DHEADER must be counted under XCDR2"
1180 );
1181 // ...and must NOT be under XCDR1, where begin_dheader is a no-op.
1182 assert_eq!(
1183 max_serialized_size(WRAPPED, EncodingVersion::Xcdr1).unwrap(),
1184 max_serialized_size(FLAT, EncodingVersion::Xcdr1).unwrap(),
1185 "XCDR1 has no DHEADER; wrapping must cost nothing"
1186 );
1187 }
1188}