zerodds_wire_codec/decode.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The bytes back into the value.
4//!
5//! `encode.rs` walks the layout plan and writes; this walks the same
6//! plan and reads. Not a second reading of the model, for the reason the
7//! encoder gives: two walks that decided the framing separately would
8//! agree with each other only until one of them changed.
9//!
10//! ## What the return direction adds
11//!
12//! Encoding is measured by comparing bytes, and a byte comparison passes
13//! a decoder that is wrong in the same way its own encoder is wrong. A
14//! round trip through one implementation passes such a defect every
15//! time; a round trip through two does not. That is the whole reason
16//! this file exists — `docs/specs/zerodds-interop-suite-1.0.md` §4, P2.
17//!
18//! ## Where reading needs a decision writing did not
19//!
20//! Two places, each of which the writer never had to face:
21//!
22//! - **A union's member.** The writer takes the member the value names;
23//! the reader has only the discriminator, so it evaluates the case
24//! labels against it, falls back to `default:`, and reads no member at
25//! all where neither matches.
26//! - **The end of the stream.** A decoder that stopped early would leave
27//! bytes unread and report success, which is the failure class this
28//! suite exists to find. Anything left over is an error.
29
30use crate::canonical;
31use crate::encode::{ProbeError, sequence_element_carries_length};
32use crate::sink::{Aggregate, Sink};
33use crate::source::Source;
34use crate::value::{Emit, MapLengthPolicy, ProbeRequest};
35
36use std::string::String;
37use std::vec::Vec;
38use zerodds_ast::{
39 Ast, Bound, CharWidth, Extensibility, PrimitiveKind, TryConstruct, TypeKind, TypeNode,
40};
41use zerodds_ast_core::{Id, Ref, ScopedName};
42use zerodds_ast_layout::{
43 DefaultedDiscriminatorMember, DepCollector, ElementPadding, Endianness, Extent,
44 FixedDigitCount, LayoutKey, MemberHeader, Purpose, WirePosition, WstringCodeUnits,
45 WstringLengthCounts, WstringLengthPolicy, WstringTerminator, WstringUnitOrder,
46 array_is_delimited, collection_is_delimited, enumeration_bit_bound, enumerator_wire_value,
47 plan, union_negative_matches_nothing,
48};
49use zerodds_wire::{ParameterRole, Reader, WcharPosition, WireError};
50
51/// What a walk carries down, the counterpart of the encoder's.
52#[derive(Debug, Clone, Copy)]
53struct Frame {
54 key: LayoutKey,
55 map_length: MapLengthPolicy,
56 /// What to do if the value about to be read will not fit this
57 /// type. DDS-XTypes 1.3 §7.2.2.7.
58 ///
59 /// Set by whichever site is about to read — a member, a collection
60 /// element, a map key or value, a union's discriminator — from the
61 /// annotation that governs *that* position. A site that does not set
62 /// it leaves `DISCARD`, which is the clause's own default for an
63 /// annotation that was not written, so a position nobody has taught
64 /// this walk about behaves as the standard says rather than as
65 /// whatever the enclosing member asked for.
66 try_construct: TryConstruct,
67 /// What the site *wrote*, before the alias chain of the type it uses
68 /// was consulted.
69 ///
70 /// Only an array needs the distinction. There is no syntax for an
71 /// array's element slot on a member declarator, so
72 /// `@try_construct(TRIM) String5 m[3]` writes the annotation on the
73 /// member and Table 17 spends it on the elements — and a use site's
74 /// own annotation has to beat a default the element's `typedef`
75 /// carries, which the already-resolved value cannot tell apart from
76 /// an inherited one.
77 written_try_construct: Option<TryConstruct>,
78 /// The member whose value is being read, when the position is one.
79 ///
80 /// `USE_DEFAULT` substitutes "the default value of the
81 /// member" (§7.2.2.7), and §7.2.2.4.4.4.9 puts the member's explicit
82 /// `@default` ahead of Table 9's zero: "An explicit definition
83 /// associates a default value with the member at the time where the
84 /// aggregated type is defined." Substituting Table 9's zero for a
85 /// member the designer gave a `@default` would be a silently wrong
86 /// value, which is the failure class this project ranks above every
87 /// other. `None` at a collection element or a discriminator, which
88 /// are not members and have only their type's default.
89 default_from: Option<Id<zerodds_ast::MemberNode>>,
90 /// Whether the type about to be read is reached as a member or as a
91 /// collection element.
92 ///
93 /// The encoder's counterpart, and it has to be threaded on this side
94 /// too: under TAO's framing a `wchar` member carries an octet count
95 /// and an element does not, so a reader that assumed the member's
96 /// form for an element would take the first half of a code unit for
97 /// a count and refuse. See `zerodds_ast_layout::WcharPosition`.
98 wchar: WcharPosition,
99 /// How many aggregates of the *value* the walk is inside.
100 ///
101 /// The encoder's counterpart, and the reason it exists on this side
102 /// too: nothing in a byte stream bounds how deeply it nests a
103 /// recursive type, and a reader that followed it would run out of
104 /// stack rather than say what was wrong. See
105 /// [`crate::MAX_VALUE_DEPTH`].
106 depth: usize,
107 /// Whether the value about to be read is the discriminator of a union.
108 ///
109 /// A bit that a bitmask discriminator does not declare is refused
110 /// there and dropped elsewhere (`read_bitmask`): the discriminator is
111 /// what selects the member, and a set with the bit left out selects
112 /// another one or none.
113 is_discriminator: bool,
114}
115
116impl Frame {
117 /// The same frame, reading a position that wrote `behaviour` and
118 /// uses `used`.
119 fn constructing(
120 self,
121 ast: &Ast,
122 behaviour: Option<TryConstruct>,
123 used: Option<Id<TypeNode>>,
124 ) -> Self {
125 Self {
126 try_construct: ast.effective_try_construct(behaviour, used),
127 written_try_construct: behaviour,
128 ..self
129 }
130 }
131
132 /// The same frame, reading the value of `member`.
133 fn for_member(
134 self,
135 ast: &Ast,
136 behaviour: Option<TryConstruct>,
137 used: Option<Id<TypeNode>>,
138 member: Id<zerodds_ast::MemberNode>,
139 ) -> Self {
140 Self {
141 default_from: Some(member),
142 ..self.constructing(ast, behaviour, used)
143 }
144 }
145
146 /// The same frame, reading a position whose behaviour is already
147 /// resolved — a collection's element, whose slot the query below
148 /// has already read.
149 const fn resolved(self, behaviour: TryConstruct) -> Self {
150 Self {
151 try_construct: behaviour,
152 written_try_construct: Some(behaviour),
153 // An element, a key or a discriminator is not a member and
154 // carries no `@default` of its own.
155 default_from: None,
156 ..self
157 }
158 }
159
160 /// The frame an element of a collection is read under.
161 ///
162 /// Separate from [`Self::resolved`] because that one also serves a
163 /// union's discriminator, which is a member position: the two
164 /// questions overlap in their call sites and not in their answers.
165 const fn in_collection(self) -> Self {
166 Self {
167 wchar: WcharPosition::Element,
168 ..self
169 }
170 }
171
172 /// The frame an aggregate's own members are read under.
173 ///
174 /// The one place the value's depth grows on this side: every
175 /// descent into an aggregate's members goes through here, so no arm
176 /// of the walk can descend without counting.
177 const fn in_aggregate(self) -> Self {
178 Self {
179 wchar: WcharPosition::Member,
180 depth: self.depth.saturating_add(1),
181 ..self
182 }
183 }
184
185 /// The refusal for a stream that nests past
186 /// [`crate::MAX_VALUE_DEPTH`], or `Ok(())` while it does not.
187 ///
188 /// # Errors
189 /// [`ProbeError::ValueTooDeep`].
190 fn within_depth(self, path: &str) -> Result<(), ProbeError> {
191 if self.depth >= crate::MAX_VALUE_DEPTH {
192 return Err(ProbeError::ValueTooDeep {
193 path: String::from(path),
194 limit: crate::MAX_VALUE_DEPTH,
195 });
196 }
197 Ok(())
198 }
199
200 /// Table 9's default for `type_`, or the member's `@default` where
201 /// the position is a member that declares one.
202 ///
203 /// One call, so that every `USE_DEFAULT` in this walk goes through
204 /// the same derivation the evolution path uses — `default.rs`, whose
205 /// documentation carries Table 9 row by row — rather than each site
206 /// spelling a zero of its own.
207 ///
208 /// # Errors
209 /// Whatever the defaulter says about a type it cannot name.
210 fn default_value<V: Source + Sink>(
211 self,
212 ast: &Ast,
213 type_: Id<TypeNode>,
214 path: &str,
215 ) -> Result<V, ProbeError> {
216 match self.default_from.and_then(|id| ast.member(id).ok()) {
217 Some(node) => crate::default::member(ast, node, type_, path),
218 None => crate::default::of_type(ast, type_, path),
219 }
220 }
221}
222
223/// What a receiving type does with a value it cannot hold.
224///
225/// One place, so the four behaviours cannot be spelled differently at
226/// the string site and the sequence site. `trimmed` is what `TRIM`
227/// produces where Table 11 defines it — a string, wstring, sequence or
228/// map cut to the receiver's bound — and `None` everywhere else;
229/// `default` is Table 9's value for the receiving type, computed only
230/// when it is wanted because a structure's default is a walk of its own.
231///
232/// # Errors
233/// [`ProbeError::Discarded`] under `DISCARD`, whatever `default`
234/// returns under `USE_DEFAULT`, and [`ProbeError::Unsupported`] for a
235/// `TRIM` the frontend should have refused.
236fn narrowed<V, D>(
237 behaviour: TryConstruct,
238 trimmed: Option<V>,
239 default: D,
240 path: &str,
241 expected: &str,
242 found: &str,
243) -> Result<V, ProbeError>
244where
245 D: FnOnce() -> Result<V, ProbeError>,
246{
247 match behaviour {
248 TryConstruct::Discard => Err(ProbeError::Discarded {
249 path: path.to_string(),
250 expected: expected.to_string(),
251 found: found.to_string(),
252 }),
253 TryConstruct::UseDefault => default(),
254 // Table 11: TRIM "only applies to elements or members of type
255 // string, wide string, sequence, or map. The behavior when
256 // applied to other element/member types is unspecified and may
257 // be treated as an error." `idl-lower`'s validation refuses such
258 // a declaration outright, so reaching here means a model that
259 // did not come through the frontend, and answering it with
260 // DISCARD would hide that.
261 TryConstruct::Trim => trimmed.ok_or(ProbeError::Unsupported {
262 path: path.to_string(),
263 kind: "`@try_construct(TRIM)` on something that cannot be trimmed",
264 }),
265 }
266}
267
268/// The number of padding octets an encapsulated payload of `payload_len`
269/// octets (encapsulation header included) carries at its end, given the
270/// low byte of its options word.
271///
272/// The low two bits of the options are the count of padding octets the
273/// writer appended (XTypes 1.3 §7.6.3.1.2), and it appends them to bring
274/// the payload to a multiple of four; a payload whose length is not one
275/// has none, whatever the options say. OpenDDS 3.34 writes a count of two
276/// on a 70-octet payload that ends right after its last data octet: cutting
277/// two octets off it cut two octets of the sample, and every such sample
278/// failed to decode. Cyclone DDS, Fast DDS and RTI Connext pad what they
279/// count, and there the count is honoured.
280///
281/// A count larger than the body after the header is no padding a writer
282/// can have added: it is answered as is, and every caller checks that the
283/// body holds it and refuses the stream when it does not (XTypes 1.3
284/// §7.6.3.1.2: the count "shall be interpreted by the receiver to
285/// determine where the serialized data exactly ended"). Reading such a
286/// stream as if the count were 0 accepted a malformed payload in silence.
287#[must_use]
288pub fn padding_present(payload_len: usize, options_low: u8) -> usize {
289 let declared = usize::from(options_low & 0x3);
290 let body = payload_len.saturating_sub(4);
291 if declared == 0 {
292 0
293 } else if declared > body {
294 declared
295 } else if payload_len % 4 != 0 {
296 0
297 } else {
298 declared
299 }
300}
301
302/// Read `bytes` as `request.type_name`, in canonical form.
303///
304/// The value that comes back is already canonical —
305/// [`crate::canonical::text`] renders it, and
306/// [`crate::canonical::difference`] compares it against the sample that
307/// went in.
308///
309/// # Errors
310/// [`ProbeError`], naming the path in the value or the offset in the
311/// stream.
312pub fn decode<V: Source + Sink>(
313 ast: &Ast,
314 request: &ProbeRequest,
315 bytes: &[u8],
316) -> Result<V, ProbeError> {
317 decode_continuing(ast, request, bytes, 0)
318}
319
320/// As [`decode`], for a value that begins at `start` in `bytes`: alignment
321/// counts from the start of `bytes`, the sample.
322///
323/// The counterpart of [`crate::encode::encode_continuing`].
324///
325/// # Errors
326/// As [`decode`]; `ProbeError::Unsupported` for an `emit` other than
327/// `Emit::Payload` when `start` is not zero.
328pub fn decode_continuing<V: Source + Sink>(
329 ast: &Ast,
330 request: &ProbeRequest,
331 bytes: &[u8],
332 start: usize,
333) -> Result<V, ProbeError> {
334 if start != 0 && request.emit != Emit::Payload {
335 return Err(ProbeError::Unsupported {
336 path: request.type_name.clone(),
337 kind: "a continuation behind an encapsulation header or as a KeyHash",
338 });
339 }
340 if request.emit == Emit::KeyHash {
341 return Err(ProbeError::Unsupported {
342 path: request.type_name.clone(),
343 kind: "a KeyHash, which is a digest and holds no value to read back",
344 });
345 }
346 let type_ = find_type(ast, &request.type_name)?;
347
348 // `--emit payload+encapsulation` is the same payload behind the
349 // four-byte header. The header is not re-read for the encoding: the
350 // caller named one, and a probe that took the stream's word for it
351 // could not be pointed at a stream to test what it does with a
352 // header it disagrees with.
353 //
354 // The **options** word is read, because §7.6.3.1.2 says it shall
355 // be: its low two bits are the count of padding bytes at the end of
356 // the serialized data, and they "shall be interpreted by the
357 // receiver to determine where the serialized data exactly ended".
358 // Cyclone, RTI and Fast DDS all write them — see
359 // `value::encapsulation_header`. A reader that ignored them would
360 // take up to three bytes of padding for data, which `trailing`
361 // forgives only because it cannot tell the two apart; with the
362 // count it can.
363 let payload = if request.emit == Emit::PayloadWithEncapsulation {
364 // Split rather than index: the header's fourth byte is read
365 // below, and taking it with a fallback would turn a stream too
366 // short to hold a header into a padding count of zero.
367 let (header, body) = bytes
368 .split_at_checked(4)
369 .ok_or_else(|| ProbeError::Mismatch {
370 path: request.type_name.clone(),
371 expected: "at least the four-byte encapsulation header".to_string(),
372 found: format!("{} bytes", bytes.len()),
373 })?;
374 // Byte 3 of the header, low two bits. The word is big-endian
375 // like the identifier, so the count is in the second byte of
376 // the pair whatever the payload's order. `padding_present` says
377 // whether the octets are there at all.
378 let declared = padding_present(bytes.len(), header[3]);
379 let end = body
380 .len()
381 .checked_sub(declared)
382 .ok_or_else(|| ProbeError::Mismatch {
383 path: request.type_name.clone(),
384 expected: format!(
385 "a payload long enough to hold the {declared} padding byte(s) the \
386 encapsulation options declared"
387 ),
388 found: format!("{} byte(s) behind the header", body.len()),
389 })?;
390 // §7.4.1.1 says the padding is zero. A non-zero byte there is
391 // either a writer that miscounted or a reader pointed at the
392 // wrong offset, and cutting it off in silence would hide both.
393 if body[end..].iter().any(|byte| *byte != 0) {
394 return Err(ProbeError::Mismatch {
395 path: request.type_name.clone(),
396 expected: format!("{declared} zero byte(s) of trailing padding, per §7.4.1.1"),
397 found: format!("{:02x?} at offset {}", &body[end..], end + 4),
398 });
399 }
400 &body[..end]
401 } else {
402 bytes
403 };
404
405 // The request's own key, assembled by the request: a reader planned
406 // under one dialect and a writer under another disagree about the
407 // framing before either touches a byte, and that reads as a wire
408 // difference without being one.
409 let frame = Frame {
410 key: request
411 .layout_key(Purpose::Data)
412 .for_sample(ast, type_)
413 .map_err(|e| ProbeError::NoLayout {
414 detail: e.to_string(),
415 })?,
416 map_length: request.map_length,
417 // The top-level type is not a member of anything, so no
418 // annotation governs it. §7.2.2.7's default stands.
419 try_construct: TryConstruct::ABSENT,
420 written_try_construct: None,
421 default_from: None,
422 wchar: WcharPosition::Member,
423 depth: 0,
424 is_discriminator: false,
425 };
426 // The same framing the writer would have used, from the same
427 // policy: the read direction takes every dialect setting the write
428 // direction does. `--dialect rti` on a decode run means "read what
429 // RTI writes". The rest of the dialect reaches this walk through
430 // `frame.key`, which is the plan it follows.
431 let mut r = Reader::with_max_align(
432 payload,
433 wire_endianness(request.endianness),
434 usize::from(request.encoding.max_alignment()),
435 )
436 .with_pl_cdr1_framing(request.pl_cdr1_framing())
437 .with_pl_cdr1_reading(request.wire_pl_cdr1_reading())
438 .with_wchar_width(request.wchar_width())
439 .with_wchar_framing(request.wire_wchar_framing())
440 .with_wstring_unit_order(request.wire_wstring_unit_order());
441 if start != 0 {
442 r.skip_to(start).map_err(|e| ProbeError::Mismatch {
443 path: request.type_name.clone(),
444 expected: format!("a value at offset {start}"),
445 found: e.to_string(),
446 })?;
447 }
448 let key = frame.key;
449 let value = read_type(&mut r, ast, type_, frame, &request.type_name)?;
450 if r.remaining() != 0 && appended_members_end_the_sample(ast, type_, key) {
451 // XTypes 1.3 §7.2.4.2: an `@appendable` type "may have
452 // additional members appended at the end", and a reader with
453 // fewer members ignores them. Under encoding version 2 the
454 // DHEADER says where they end and `read_aggregate` skipped to
455 // it; under version 1 there is no delimiter, and for the
456 // top-level type the sample's own extent is the only one — the
457 // octets after the reader's last member are the writer's
458 // appended members and nothing else can be there. A nested
459 // `@appendable` type has the enclosing type's bytes behind it
460 // and keeps the refusal below (`evo-appendable-nested`).
461 //
462 // Measured on the OMG `dds-xtypes` suite, `ext_appendable_struct_3`
463 // under `-x 1`: a two-member `@appendable` writer read by its
464 // one-member version answered "did not decode" here.
465 return Ok(value);
466 }
467 trailing(&r, &request.type_name)?;
468 Ok(value)
469}
470
471/// Read one value of `request.type_name` from the start of `bytes` and say
472/// how many octets it took.
473///
474/// [`decode`] reads a whole payload and refuses octets left behind it. A
475/// transport that packs several serialized samples into one buffer without a
476/// length for each (DDS-XRCE 1.0 Annex A `sequence<SampleData>`) has only the
477/// type to tell where one sample ends and the next begins: this reads the
478/// first and returns where it stopped. The octets after it are not looked at.
479///
480/// The payload is read without an encapsulation header (`Emit::Payload`),
481/// from alignment origin 0.
482///
483/// # Errors
484/// [`ProbeError`], as [`decode`]; `Unsupported` for any `emit` but
485/// `Emit::Payload`.
486pub fn decode_prefix<V: Source + Sink>(
487 ast: &Ast,
488 request: &ProbeRequest,
489 bytes: &[u8],
490) -> Result<(V, usize), ProbeError> {
491 if request.emit != Emit::Payload {
492 return Err(ProbeError::Unsupported {
493 path: request.type_name.clone(),
494 kind: "a prefix read of anything but the bare payload",
495 });
496 }
497 let type_ = find_type(ast, &request.type_name)?;
498 let frame = Frame {
499 key: request
500 .layout_key(Purpose::Data)
501 .for_sample(ast, type_)
502 .map_err(|e| ProbeError::NoLayout {
503 detail: e.to_string(),
504 })?,
505 map_length: request.map_length,
506 try_construct: TryConstruct::ABSENT,
507 written_try_construct: None,
508 default_from: None,
509 wchar: WcharPosition::Member,
510 depth: 0,
511 is_discriminator: false,
512 };
513 let mut r = Reader::with_max_align(
514 bytes,
515 wire_endianness(request.endianness),
516 usize::from(request.encoding.max_alignment()),
517 )
518 .with_pl_cdr1_framing(request.pl_cdr1_framing())
519 .with_pl_cdr1_reading(request.wire_pl_cdr1_reading())
520 .with_wchar_width(request.wchar_width())
521 .with_wchar_framing(request.wire_wchar_framing())
522 .with_wstring_unit_order(request.wire_wstring_unit_order());
523 let value = read_type(&mut r, ast, type_, frame, &request.type_name)?;
524 Ok((value, bytes.len() - r.remaining()))
525}
526
527/// Whether octets left after the top-level type are appended members of
528/// an evolved `@appendable` writer rather than an unread tail: encoding
529/// version 1, where no DHEADER delimits the aggregate, and the type is
530/// `@appendable`. §7.2.4.2, and see the caller.
531fn appended_members_end_the_sample(ast: &Ast, type_: Id<TypeNode>, key: LayoutKey) -> bool {
532 if !key.encoding.is_v1() {
533 return false;
534 }
535 let Ok(node) = ast.type_(type_) else {
536 return false;
537 };
538 matches!(
539 node.kind,
540 TypeKind::Structure { .. } | TypeKind::Union { .. }
541 ) && zerodds_ast_layout::xcdr::extensibility_of(ast, node, key) == Extensibility::Appendable
542}
543
544/// Validate the currently supported inverse holder shape without reading data.
545///
546/// Primitive, string, enumeration, alias and nested-structure keys are supported.
547/// Optional/external, collection and union key contexts are explicitly refused.
548/// # Errors
549/// Unsupported holder shape or invalid layout.
550pub fn validate_key_holder(ast: &Ast, request: &ProbeRequest) -> Result<(), ProbeError> {
551 holder_members(ast, request).map(|_| ())
552}
553
554fn holder_members(
555 ast: &Ast,
556 request: &ProbeRequest,
557) -> Result<(Id<TypeNode>, Vec<MemberSlot>), ProbeError> {
558 let type_ = find_type(ast, &request.type_name)?;
559 let bad = || ProbeError::Unsupported {
560 path: request.type_name.clone(),
561 kind: "inverse holder requires structure keys with supported scalar/alias/nested contexts",
562 };
563 let node = ast.type_(type_).map_err(|_| bad())?;
564 if !matches!(node.kind, TypeKind::Structure { .. }) {
565 return Err(bad());
566 }
567 let key = request.key_holder_key();
568 // The holder is PLAIN_CDR2 (XTypes 1.3 §7.6.8) and holds no PL_CDR1
569 // parameter, so how those are framed and read (`wire.pl_cdr1_headers`,
570 // `wire.pl_cdr1_reading`) leaves it in the specification's form.
571 let specification = LayoutKey::key(zerodds_ast_layout::Encoding::PlainCdr2);
572 let holder_form = LayoutKey {
573 pl_cdr1_headers: specification.pl_cdr1_headers,
574 pl_cdr1_reading: specification.pl_cdr1_reading,
575 ..key
576 };
577 if holder_form != specification {
578 return Err(bad());
579 }
580 let layout = plan(
581 ast,
582 type_,
583 request.layout_key(Purpose::Data),
584 &mut DepCollector::default(),
585 )
586 .map_err(|e| ProbeError::NoLayout {
587 detail: e.to_string(),
588 })?;
589 let members = member_table(ast, type_, &layout);
590 let keys = zerodds_ast_layout::keyhash::key_members_with(
591 ast,
592 type_,
593 key.key_holder_nesting,
594 key.key_holder_order,
595 )
596 .map_err(|e| ProbeError::NoLayout {
597 detail: e.to_string(),
598 })?;
599 if keys.is_empty() {
600 return Err(ProbeError::NoKey {
601 type_name: request.type_name.clone(),
602 });
603 }
604 for id in keys {
605 let Some(member) = members
606 .iter()
607 .find(|m| ast.member(m.member).is_ok_and(|n| n.node_id == id))
608 else {
609 continue;
610 };
611 let node = ast.member(member.member).map_err(|_| bad())?;
612 if member.optional || node.facets.xtypes.as_ref().is_some_and(|x| x.is_external) {
613 return Err(bad());
614 }
615 validate_holder_context(ast, member.type_, key, 0, &request.type_name)?;
616 }
617 Ok((type_, members))
618}
619
620/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH` is checked
621/// before inspecting a type. Both alias and structure edges increment depth.
622fn validate_holder_context(
623 ast: &Ast,
624 type_: Id<TypeNode>,
625 key: LayoutKey,
626 depth: usize,
627 path: &str,
628) -> Result<(), ProbeError> {
629 let bad = || ProbeError::Unsupported {
630 path: path.into(),
631 kind: "unsupported or recursive inverse key context",
632 };
633 if depth >= crate::MAX_VALUE_DEPTH {
634 return Err(bad());
635 }
636 let node = ast.type_(type_).map_err(|_| bad())?;
637 match &node.kind {
638 TypeKind::Primitive(_) | TypeKind::String { .. } | TypeKind::Enumeration { .. } => Ok(()),
639 TypeKind::Alias { aliased } => validate_holder_context(
640 ast,
641 aliased.resolved().ok_or_else(bad)?,
642 key,
643 depth + 1,
644 path,
645 ),
646 TypeKind::Structure { .. } => {
647 let layout = plan(ast, type_, key, &mut DepCollector::default()).map_err(|e| {
648 ProbeError::NoLayout {
649 detail: e.to_string(),
650 }
651 })?;
652 let inside = zerodds_ast_layout::member_key(ast, type_, key);
653 for slot in member_table(ast, type_, &layout) {
654 let member = ast.member(slot.member).map_err(|_| bad())?;
655 if slot.optional || member.facets.xtypes.as_ref().is_some_and(|x| x.is_external) {
656 return Err(bad());
657 }
658 validate_holder_context(ast, slot.type_, inside, depth + 1, path)?;
659 }
660 Ok(())
661 }
662 _ => Err(bad()),
663 }
664}
665
666/// Decode the writer's own `serializedKey` (DDSI-RTPS 2.5 §9.4.2.11) — the
667/// key-only payload behind a dispose/unregister DATA's K flag — in the
668/// representation and byte order `request` names.
669///
670/// Unlike [`key_holder`], which always reads the canonical big-endian
671/// PLAIN_CDR2 Key Holder XTypes 1.3 §7.6.8 fixes for computing the
672/// KeyHash, this reads exactly what a writer put on the wire: only the
673/// `@key` members — recursing into a nested keyed aggregate's own `@key`
674/// members the same way — in declaration order, framed by the type's OWN
675/// extensibility (a DHEADER for `@appendable`/`@mutable` under XCDR2, a
676/// per-member EMHEADER for `@mutable`, nothing for `@final` or under
677/// XCDR1), in `request`'s own encoding and endianness. Measured against
678/// Eclipse Cyclone 11.0.1 (`ddsi_serdata_from_sample(sertype, SDK_KEY,
679/// …)` + `ddsi_serdata_to_ser`): the key member kept its declared
680/// position among the type's other members (a `@key` member need not be
681/// the first, or even present, in what a truncated-sample reading of the
682/// same bytes would decode), and a nested `@appendable` key member came
683/// back with its own DHEADER inside the outer one.
684///
685/// `request.key_holder`/`key_holder_order`/`key_holder_nesting` select
686/// this reading: `AsDeclared` combined with `Declaration` order (and
687/// `Recursive` nesting, already the default) is the wire form; nothing
688/// here touches the canonical hash-computation form (`ForcedFinal`
689/// combined with `AscendingMemberId`) [`key_holder`] and the rest of
690/// this module use.
691///
692/// Non-key members come back at their default value — the wire payload
693/// never carried them — through the same `Purpose::Key` "holder" path
694/// `read_aggregate` already takes for [`key_holder`]'s canonical form;
695/// a caller after the KeyHash reads it off the decoded key values
696/// through the existing canonical encoder, never from this value
697/// directly.
698///
699/// Unlike [`decode`], nothing here tolerates leftover bytes as a
700/// forward-evolved writer's appended members: a `serializedKey` carries
701/// only the key members and nothing else can legitimately follow one, so
702/// any byte `trailing` finds past the declared padding is refused.
703///
704/// # Errors
705/// [`ProbeError`] naming the path or the offset: a context [`plan()`]
706/// cannot describe, a payload the plan's shape does not fit, a value the
707/// planned type refuses, or bytes left over the padding count does not
708/// account for.
709pub fn serialized_key<V: Source + Sink>(
710 ast: &Ast,
711 request: &ProbeRequest,
712 bytes: &[u8],
713) -> Result<V, ProbeError> {
714 let type_ = find_type(ast, &request.type_name)?;
715 let payload = if request.emit == Emit::PayloadWithEncapsulation {
716 // Same options-word padding rule as `decode` — §7.6.3.1.2's low
717 // two bits, measured on Cyclone's own `serializedKey` octets
718 // (e.g. `S` above: `0002` for two trailing zero bytes).
719 let (header, body) = bytes
720 .split_at_checked(4)
721 .ok_or_else(|| ProbeError::Mismatch {
722 path: request.type_name.clone(),
723 expected: "at least the four-byte encapsulation header".to_string(),
724 found: format!("{} bytes", bytes.len()),
725 })?;
726 let declared = padding_present(bytes.len(), header[3]);
727 let end = body
728 .len()
729 .checked_sub(declared)
730 .ok_or_else(|| ProbeError::Mismatch {
731 path: request.type_name.clone(),
732 expected: format!(
733 "a payload long enough to hold the {declared} padding byte(s) the \
734 encapsulation options declared"
735 ),
736 found: format!("{} byte(s) behind the header", body.len()),
737 })?;
738 if body[end..].iter().any(|byte| *byte != 0) {
739 return Err(ProbeError::Mismatch {
740 path: request.type_name.clone(),
741 expected: format!("{declared} zero byte(s) of trailing padding, per §7.4.1.1"),
742 found: format!("{:02x?} at offset {}", &body[end..], end + 4),
743 });
744 }
745 &body[..end]
746 } else {
747 bytes
748 };
749 let frame = Frame {
750 key: request.layout_key(Purpose::Key),
751 map_length: request.map_length,
752 try_construct: TryConstruct::ABSENT,
753 written_try_construct: None,
754 default_from: None,
755 wchar: WcharPosition::Member,
756 depth: 0,
757 is_discriminator: false,
758 };
759 let mut r = Reader::with_max_align(
760 payload,
761 wire_endianness(request.endianness),
762 usize::from(request.encoding.max_alignment()),
763 )
764 .with_pl_cdr1_framing(request.pl_cdr1_framing())
765 .with_pl_cdr1_reading(request.wire_pl_cdr1_reading())
766 .with_wchar_width(request.wchar_width())
767 .with_wchar_framing(request.wire_wchar_framing())
768 .with_wstring_unit_order(request.wire_wstring_unit_order());
769 let value = read_type(&mut r, ast, type_, frame, &request.type_name)?;
770 trailing(&r, &request.type_name)?;
771 Ok(value)
772}
773
774/// Decode an actual PLAIN_CDR2-BE holder using the shared member plan and defaults.
775/// # Errors
776/// Unsupported key context, malformed holder or any trailing octets.
777pub fn key_holder<V: Source + Sink>(
778 ast: &Ast,
779 request: &ProbeRequest,
780 bytes: &[u8],
781) -> Result<V, ProbeError> {
782 let (value, consumed) = key_holder_prefix(ast, request, bytes)?;
783 if consumed != bytes.len() {
784 return Err(ProbeError::Mismatch {
785 path: request.type_name.clone(),
786 expected: "fully consumed key holder".into(),
787 found: format!("{} trailing bytes", bytes.len() - consumed),
788 });
789 }
790 Ok(value)
791}
792
793/// Decode a PLAIN_CDR2-BE holder from the start of `bytes` and say how many
794/// octets it took; what follows is the caller's (DDSI-RTPS 2.5 §9.6.4.8
795/// Step 5.1: a KeyHash is the holder padded with zeros to 16 octets).
796/// # Errors
797/// Unsupported key context or a malformed holder.
798pub fn key_holder_prefix<V: Source + Sink>(
799 ast: &Ast,
800 request: &ProbeRequest,
801 bytes: &[u8],
802) -> Result<(V, usize), ProbeError> {
803 let (type_, members) = holder_members(ast, request)?;
804 let key = request.key_holder_key();
805 let keys = zerodds_ast_layout::keyhash::key_members_with(
806 ast,
807 type_,
808 key.key_holder_nesting,
809 key.key_holder_order,
810 )
811 .map_err(|e| ProbeError::NoLayout {
812 detail: e.to_string(),
813 })?;
814 let frame = Frame {
815 key,
816 map_length: request.map_length,
817 try_construct: TryConstruct::ABSENT,
818 written_try_construct: None,
819 default_from: None,
820 wchar: WcharPosition::Member,
821 depth: 0,
822 is_discriminator: false,
823 };
824 // The writer's cap (`encode::key_holder`): PLAIN_CDR2's four, unless
825 // the dialect aligns the holder to its data encoding or packs it.
826 let mut reader = Reader::with_max_align(
827 bytes,
828 zerodds_wire::Endianness::Big,
829 usize::from(key.key_holder_alignment.max_alignment(key.encoding)),
830 )
831 .with_wchar_width(crate::value::wire_wchar_width_of(key.wchar_width))
832 .with_wchar_framing(crate::value::wire_wchar_framing_of(key.wchar_framing))
833 .with_wstring_unit_order(crate::value::wire_wstring_unit_order_of(
834 key.wstring_unit_order,
835 ));
836 let mut out = Aggregate::new();
837 for id in keys {
838 let Some(slot) = members
839 .iter()
840 .find(|m| ast.member(m.member).is_ok_and(|n| n.node_id == id))
841 else {
842 continue;
843 };
844 out.insert(
845 slot.name.clone(),
846 read_type(
847 &mut reader,
848 ast,
849 slot.type_,
850 frame,
851 &format!("{}.{}", request.type_name, slot.name),
852 )?,
853 );
854 }
855 let consumed = bytes.len() - reader.remaining();
856 defaults_from(ast, &members, &mut out, &request.type_name)?;
857 Ok((V::aggregate(out), consumed))
858}
859
860/// Nothing may be left over that is not padding.
861///
862/// A reader that stopped short and said nothing is the silent-drop shape
863/// this suite is built to catch: the sample would come back looking
864/// plausible with its last members missing. Padding is the one thing a
865/// writer can legitimately leave behind — a `@mutable` sentinel is
866/// aligned, and RTI leaves uninitialised bytes in the gap — so the
867/// remainder is allowed to be padding and nothing else.
868///
869/// # Padding is what alignment asks for, not anything under the cap
870///
871/// This used to accept any remainder shorter than the alignment cap,
872/// which is up to three bytes under XCDR2 and seven under XCDR1
873/// whatever the position. That is more than alignment can ever require:
874/// from an offset the padding to the cap is fixed, and a byte beyond it
875/// is not padding, it is a member nobody read.
876///
877/// The two bytes a `wstring` read under the wrong
878/// [`WstringLengthPolicy`] leaves behind are exactly that shape —
879/// `04000000 61006200 ab` read as a character count returns `"a"`,
880/// takes `62` for the trailing octet and leaves two bytes. The old
881/// check let it through and the sample came back with a one-character
882/// string and a wrong octet.
883///
884/// # And the padding at the end of a payload reaches four, not the cap
885///
886/// §7.4.1.1 fixes the amount: "[RTPS] states that following the
887/// serialized data submessage element, padding bytes shall be added so
888/// that the next submessage starts at a 4-byte offset … any padding
889/// bytes added at the end of the serialized data shall be set to zero."
890/// Four, whatever `MAXALIGN` is — so under XCDR1, where the cap is 8, a
891/// remainder of five to seven bytes is not padding either.
892///
893/// The difference is a whole value. `evo-appendable-nested` under XCDR1:
894/// a three-member `@appendable` nested in a `@final` type, read by a
895/// two-member version. The reader takes the third member's bytes for the
896/// outer type's `long tail` and hands back 65533 where 42 was sent, then
897/// stops four bytes early. Against the old cap of 8 those four bytes sat
898/// inside the allowance and the wrong value came back with no error;
899/// against four they do not. Cyclone refuses the same payload. RTI reads
900/// the same wrong 65533 this project used to — measured 2026-08-09.
901fn trailing(r: &Reader<'_>, path: &str) -> Result<(), ProbeError> {
902 let left = r.remaining();
903 if left == 0 {
904 return Ok(());
905 }
906 let cap = r.max_align().min(4);
907 let padding = (cap - (r.position() % cap)) % cap;
908 if left <= padding {
909 return Ok(());
910 }
911 Err(ProbeError::Mismatch {
912 path: path.to_string(),
913 expected: format!("the whole payload consumed, bar at most {padding} byte(s) of padding"),
914 found: format!("{left} byte(s) left at offset {}", r.position()),
915 })
916}
917
918const fn wire_endianness(endianness: Endianness) -> zerodds_wire::Endianness {
919 match endianness {
920 Endianness::Little => zerodds_wire::Endianness::Little,
921 Endianness::Big => zerodds_wire::Endianness::Big,
922 }
923}
924
925fn find_type(ast: &Ast, name: &str) -> Result<Id<TypeNode>, ProbeError> {
926 let segments: Vec<String> = name
927 .split("::")
928 .filter(|segment| !segment.is_empty())
929 .map(std::string::ToString::to_string)
930 .collect();
931 ast.find_by_scoped_name(&ScopedName::absolute(segments))
932 .ok()
933 .and_then(zerodds_ast::AnyId::as_type)
934 .ok_or_else(|| ProbeError::NoSuchType {
935 name: name.to_string(),
936 })
937}
938
939/// Read one value of one type.
940///
941/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. Every
942/// descent into an aggregate goes through `Frame::in_aggregate`, and
943/// `read_aggregate` and `read_union` refuse a stream past the count with
944/// `ProbeError::ValueTooDeep` before a byte is read. The collection arms
945/// descend the value too; the typedef arm does not, and is finite because
946/// `read_aggregate` plans the type first and `zerodds_ast_layout::plan`
947/// refuses a graph deeper than its own `MAX_DEPTH`
948/// (`crates/ast-layout/src/plan.rs:388`).
949///
950/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. Every
951/// descent into an aggregate goes through `Frame::in_aggregate`, and
952/// `read_aggregate` and `read_union` refuse a stream past the count with
953/// `ProbeError::ValueTooDeep` before a byte is read. The collection arms
954/// descend the value too; the typedef arm does not, and is finite because
955/// `read_aggregate` plans the type first and `zerodds_ast_layout::plan`
956/// refuses a graph deeper than its own `MAX_DEPTH`
957/// (`crates/ast-layout/src/plan.rs:388`).
958#[allow(clippy::too_many_lines)]
959fn read_type<V: Source + Sink>(
960 r: &mut Reader<'_>,
961 ast: &Ast,
962 type_: Id<TypeNode>,
963 frame: Frame,
964 path: &str,
965) -> Result<V, ProbeError> {
966 let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
967 detail: format!("{error}"),
968 })?;
969 match &node.kind {
970 TypeKind::Primitive(kind) => read_primitive(r, *kind, frame.wchar, path),
971 TypeKind::String {
972 width: CharWidth::Char,
973 bound,
974 } => {
975 let text = wire(r.read_string(), path)?;
976 fit_string(ast, type_, text, *bound, CharWidth::Char, frame, path)
977 }
978 TypeKind::String {
979 width: CharWidth::WChar,
980 bound,
981 } => {
982 let text = if frame.key.wstring_code_units == WstringCodeUnits::Utf8 {
983 read_wstring_utf8(
984 r,
985 frame.key.wstring_length,
986 layout_position(frame.wchar),
987 path,
988 )?
989 } else {
990 read_wstring(
991 r,
992 frame.key.wstring_length,
993 frame.key.wstring_unit_order,
994 // The position the `wstring` is reached from, which the
995 // frame already carries for the scalar `wchar`. Passed
996 // and not assumed: C1/C2/C3/C4 are measured to answer
997 // the two positions alike in nine implementations, and
998 // an answer reached by not asking is how that stops
999 // being a measurement.
1000 layout_position(frame.wchar),
1001 path,
1002 )?
1003 };
1004 fit_string(ast, type_, text, *bound, CharWidth::WChar, frame, path)
1005 }
1006 TypeKind::Fixed { digits, scale } => {
1007 read_fixed(r, *digits, *scale, frame.key.fixed_digits, path)
1008 }
1009 TypeKind::Alias { aliased } => {
1010 let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
1011 detail: format!("{path}: the typedef target is unresolved"),
1012 })?;
1013 read_type(r, ast, target, frame, path)
1014 }
1015 TypeKind::Enumeration { .. } => read_enumeration(r, ast, type_, frame, path),
1016 TypeKind::Bitmask { .. } => read_bitmask(r, ast, type_, frame.is_discriminator, path),
1017 TypeKind::Bitset { .. } => read_bitset(r, ast, type_, frame, path),
1018 TypeKind::Sequence { element, bound } => {
1019 let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
1020 detail: format!("{path}: the sequence element is unresolved"),
1021 })?;
1022 if collection_is_delimited(ast, element, frame.key) {
1023 let _ = wire(r.read_dheader(), path)?;
1024 }
1025 let count = wire(r.read_u32(), path)? as usize;
1026 if count > r.remaining() {
1027 return Err(ProbeError::Mismatch {
1028 path: path.to_string(),
1029 expected: "an element count the stream can hold".to_string(),
1030 found: format!("{count} with {} byte(s) left", r.remaining()),
1031 });
1032 }
1033 // Every element is read whatever the bound says, and the
1034 // trim happens afterwards. Stopping at the bound would leave
1035 // the rest of the sequence in the stream and put every
1036 // following member at the wrong offset — a decode that
1037 // succeeds and is wrong, which is worse than the overflow it
1038 // was avoiding. OpenDDS does the same and then skips to the
1039 // collection's end.
1040 let inside = frame
1041 .resolved(ast.element_try_construct(type_, None))
1042 .in_collection();
1043 let mut items = Vec::with_capacity(count);
1044 // The mirror of the writer: a `sequence` element of a
1045 // non-`@final` type is preceded by its own octet length under
1046 // `SequenceElementDelimiter::BeforeEachNonFinalElement`, and
1047 // `ElementPadding::Packed` counts every element's alignment
1048 // from the element's own first octet.
1049 let carries_length = sequence_element_carries_length(ast, element, frame.key)?;
1050 let packed = frame.key.element_padding == ElementPadding::Packed;
1051 for index in 0..count {
1052 let element_path = format!("{path}[{index}]");
1053 let saved = packed.then(|| r.begin_packed_element());
1054 if carries_length {
1055 let declared = wire(r.read_u32(), &element_path)? as usize;
1056 let before = r.remaining();
1057 items.push(read_type(r, ast, element, inside, &element_path)?);
1058 let consumed = before.saturating_sub(r.remaining());
1059 if consumed != declared {
1060 return Err(ProbeError::Mismatch {
1061 path: element_path,
1062 expected: format!("an element of exactly {declared} octet(s)"),
1063 found: format!("{consumed}"),
1064 });
1065 }
1066 } else {
1067 items.push(read_type(r, ast, element, inside, &element_path)?);
1068 }
1069 if let Some(saved) = saved {
1070 r.finish_packed_element(saved);
1071 }
1072 }
1073 let limit = bound_value(ast, bound);
1074 fit_collection(ast, items, limit, type_, frame, path, "elements")
1075 }
1076 TypeKind::Array { element, dims } => {
1077 let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
1078 detail: format!("{path}: the array element is unresolved"),
1079 })?;
1080 let counts = dimensions(ast, dims);
1081 if array_is_delimited(ast, element, frame.key) {
1082 let _ = wire(r.read_dheader(), path)?;
1083 }
1084 // An array's bounds must match the sender's — Table 17
1085 // requires `T1.bounds[] == T2.bounds[]` — so an array never
1086 // overflows and has no member-level behaviour of its own.
1087 // Only its elements can fail, and `element_try_construct`
1088 // lets a member's own annotation reach them, which is the
1089 // only syntax there is for annotating an array's element.
1090 let inside = frame
1091 .resolved(ast.element_try_construct(type_, frame.written_try_construct))
1092 .in_collection();
1093 read_array_dimension(r, ast, element, inside, path, &counts)
1094 }
1095 TypeKind::Map {
1096 key,
1097 value: entry,
1098 bound,
1099 } => read_map(r, ast, type_, key, entry, bound, frame, path),
1100 TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
1101 read_aggregate(r, ast, type_, frame, path)
1102 }
1103 TypeKind::Union { .. } => read_union(r, ast, type_, frame, path),
1104 other => Err(ProbeError::Unfilled {
1105 path: path.to_string(),
1106 kind: other.as_str(),
1107 }),
1108 }
1109}
1110
1111/// A string that has been read, against the bound the receiver declares.
1112///
1113/// The bound is not on the wire — a `string<32>` and a `string<5>`
1114/// serialise identically — so the whole value is read first and the fit
1115/// is decided here. Table 16: "An object O2 of type T2 can-construct an
1116/// object of type T1 if and only if O2.length <= T1.length."
1117///
1118/// # What a bound counts
1119///
1120/// Octets for a narrow string and code units for a wide one, which is
1121/// what the bound means to every peer that has to hold the value:
1122/// `string<5>` is `char[6]` in the C mapping of all four DDS
1123/// implementations, and `fixed_string<5>` in Fast DDS's. Counting
1124/// Unicode scalar values instead would accept a five-character value of
1125/// ten octets that no C peer can hold. The trim still lands on a
1126/// character boundary, because a half-decoded UTF-8 sequence is not a
1127/// string at all.
1128fn fit_string<V: Source + Sink>(
1129 ast: &Ast,
1130 type_: Id<TypeNode>,
1131 text: String,
1132 bound: Bound,
1133 width: CharWidth,
1134 frame: Frame,
1135 path: &str,
1136) -> Result<V, ProbeError> {
1137 let Some(limit) = bound_value(ast, &bound).and_then(|limit| usize::try_from(limit).ok()) else {
1138 return Ok(V::text(text));
1139 };
1140 let length = match width {
1141 CharWidth::Char => text.len(),
1142 CharWidth::WChar => text.chars().map(char::len_utf16).sum(),
1143 };
1144 if length <= limit {
1145 return Ok(V::text(text));
1146 }
1147 let trimmed = V::text(trim_text(&text, limit, width));
1148 let found = format!("{length} of them");
1149 let unit = match width {
1150 CharWidth::Char => "octets",
1151 CharWidth::WChar => "code units",
1152 };
1153 narrowed(
1154 frame.try_construct,
1155 Some(trimmed),
1156 || frame.default_value(ast, type_, path),
1157 path,
1158 &format!("at most {limit} {unit}"),
1159 &found,
1160 )
1161}
1162
1163/// `text` cut to `limit` units, never inside a character.
1164///
1165/// Table 11: "The order of the characters in the string ... is
1166/// preserved", so this takes a prefix and never reorders or elides.
1167fn trim_text(text: &str, limit: usize, width: CharWidth) -> String {
1168 let mut used = 0usize;
1169 let mut out = String::new();
1170 for character in text.chars() {
1171 let size = match width {
1172 CharWidth::Char => character.len_utf8(),
1173 CharWidth::WChar => character.len_utf16(),
1174 };
1175 // A character that would straddle the bound is left out whole. A
1176 // C peer holding `char[limit + 1]` cannot store half of one
1177 // either, and half a UTF-8 sequence is not text.
1178 if used + size > limit {
1179 break;
1180 }
1181 used += size;
1182 out.push(character);
1183 }
1184 out
1185}
1186
1187/// A collection that has been read, against the bound the receiver
1188/// declares.
1189///
1190/// Table 17, the `SEQUENCE_TYPE` and `MAP_TYPE` rows: "An object O2 of
1191/// type T2 can construct T1 if and only if O2.length <= T1.length."
1192/// §7.3.1.2.1.12.3 is the `TRIM` case, and it takes the **first**
1193/// elements: "a sequence of 4 longs containing the first four elements
1194/// of O2.member".
1195fn fit_collection<V: Source + Sink>(
1196 ast: &Ast,
1197 items: Vec<V>,
1198 limit: Option<i128>,
1199 type_: Id<TypeNode>,
1200 frame: Frame,
1201 path: &str,
1202 what: &str,
1203) -> Result<V, ProbeError> {
1204 let Some(limit) = limit.and_then(|limit| usize::try_from(limit).ok()) else {
1205 return Ok(V::collection(items));
1206 };
1207 if items.len() <= limit {
1208 return Ok(V::collection(items));
1209 }
1210 let found = format!("{}", items.len());
1211 let mut trimmed = items;
1212 trimmed.truncate(limit);
1213 narrowed(
1214 frame.try_construct,
1215 Some(V::collection(trimmed)),
1216 || frame.default_value(ast, type_, path),
1217 path,
1218 &format!("at most {limit} {what}"),
1219 &found,
1220 )
1221}
1222
1223/// The declared extents of an array, in declaration order.
1224fn dimensions(ast: &Ast, dims: &[Id<zerodds_ast::ConstExprNode>]) -> Vec<usize> {
1225 let mut counts = Vec::with_capacity(dims.len());
1226 for dim in dims {
1227 // A dimension that does not evaluate is a zero-length one, which
1228 // is what the encoder also does with it — the two have to make
1229 // the same mistake or the round trip reports the frontend's gap
1230 // as a wire difference.
1231 let count = match ast.eval_integral(*dim) {
1232 Ok(value) => usize::try_from(value).unwrap_or(0),
1233 Err(_) => 0,
1234 };
1235 counts.push(count);
1236 }
1237 counts
1238}
1239
1240/// zerodds-lint: recursion-depth 8 — one frame per array dimension.
1241/// `counts` drops its head at each frame and the innermost element goes to
1242/// `read_type`, which is bounded by `crate::MAX_VALUE_DEPTH`.
1243fn read_array_dimension<V: Source + Sink>(
1244 r: &mut Reader<'_>,
1245 ast: &Ast,
1246 element: Id<TypeNode>,
1247 frame: Frame,
1248 path: &str,
1249 counts: &[usize],
1250) -> Result<V, ProbeError> {
1251 let Some((&count, rest)) = counts.split_first() else {
1252 return read_type(r, ast, element, frame, path);
1253 };
1254 let mut items = Vec::with_capacity(count);
1255 for index in 0..count {
1256 let saved =
1257 (frame.key.element_padding == ElementPadding::Packed).then(|| r.begin_packed_element());
1258 items.push(read_array_dimension(
1259 r,
1260 ast,
1261 element,
1262 frame,
1263 &format!("{path}[{index}]"),
1264 rest,
1265 )?);
1266 if let Some(saved) = saved {
1267 r.finish_packed_element(saved);
1268 }
1269 }
1270 Ok(V::collection(items))
1271}
1272
1273/// A structure or a valuetype, framed as its plan says.
1274///
1275/// Three shapes, and which one applies is the extensibility, read the
1276/// same way the planner reads it rather than inferred back out of the
1277/// plan it produced.
1278fn read_aggregate<V: Source + Sink>(
1279 r: &mut Reader<'_>,
1280 ast: &Ast,
1281 type_: Id<TypeNode>,
1282 frame: Frame,
1283 path: &str,
1284) -> Result<V, ProbeError> {
1285 // Before a byte is read: a stream can nest a recursive type as
1286 // deeply as it likes, and following it is how a reader runs out of
1287 // stack instead of saying what is wrong.
1288 frame.within_depth(path)?;
1289
1290 // `ElementPadding::Packed`: an aggregate is its own alignment block
1291 // on the read side too. See the writer's `write_aggregate`.
1292 let packed = frame.key.element_padding == ElementPadding::Packed;
1293 let saved_origin = packed.then(|| r.begin_packed_element());
1294
1295 let mut deps = DepCollector::new();
1296 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
1297 detail: format!("{error}"),
1298 })?;
1299
1300 let end = if layout.needs_dheader {
1301 let length = wire(r.read_dheader(), path)?;
1302 Some(r.position().saturating_add(length))
1303 } else {
1304 None
1305 };
1306
1307 let members = member_table(ast, type_, &layout);
1308 let node = ast.type_(type_).map_err(|e| ProbeError::NoLayout {
1309 detail: e.to_string(),
1310 })?;
1311 let extensibility = zerodds_ast_layout::xcdr::extensibility_of(ast, node, frame.key);
1312 let holder = frame.key.purpose == Purpose::Key;
1313 let original_key = frame.key;
1314 let frame = Frame {
1315 key: zerodds_ast_layout::member_key(ast, type_, frame.key),
1316 ..frame
1317 };
1318 let mutable = extensibility == Extensibility::Mutable
1319 && frame.key.aggregate_delimiter.frames_mutable_members();
1320 let mut out = if mutable && frame.key.encoding.is_v1() {
1321 read_parameter_list(r, ast, &members, frame, path)?
1322 } else if mutable {
1323 read_member_headers(r, ast, &members, frame, path, end)?
1324 } else {
1325 let tail = Tail {
1326 end,
1327 appendable: extensibility == Extensibility::Appendable,
1328 };
1329 read_in_plan_order(r, ast, &members, frame, path, tail)?
1330 };
1331
1332 if let Some(end) = end {
1333 if r.position() > end {
1334 return Err(ProbeError::Mismatch {
1335 path: path.to_string(),
1336 expected: format!("the members to end at offset {end}, as the DHEADER said"),
1337 found: format!("offset {}", r.position()),
1338 });
1339 }
1340 // The delimiter is what a reader skips an unknown tail by. An
1341 // appendable type read by a peer that has fewer members than the
1342 // writer had leaves exactly such a tail, and the DHEADER is the
1343 // only thing that says where it ends.
1344 skip_to(r, end);
1345 }
1346 if holder {
1347 let data_key = LayoutKey {
1348 purpose: Purpose::Data,
1349 ..original_key
1350 };
1351 let full = plan(ast, type_, data_key, &mut DepCollector::default()).map_err(|e| {
1352 ProbeError::NoLayout {
1353 detail: e.to_string(),
1354 }
1355 })?;
1356 defaults_from(ast, &member_table(ast, type_, &full), &mut out, path)?;
1357 }
1358 if let Some(saved) = saved_origin {
1359 r.finish_packed_element(saved);
1360 }
1361 Ok(V::aggregate(out))
1362}
1363
1364/// What the plan says about each member, with the model's own answer for
1365/// the name, the type and whether it may be left out.
1366struct MemberSlot {
1367 name: String,
1368 type_: Id<TypeNode>,
1369 optional: bool,
1370 member_id: u32,
1371 header: MemberHeader,
1372 /// The declaration, for the default value it may carry.
1373 member: Id<zerodds_ast::MemberNode>,
1374 /// `@try_construct` as the member wrote it, before the alias chain
1375 /// of its type is consulted. DDS-XTypes 1.3 §7.2.2.7.
1376 try_construct: Option<TryConstruct>,
1377}
1378
1379impl MemberSlot {
1380 /// The frame this member's value is read under.
1381 fn frame(&self, ast: &Ast, frame: Frame) -> Frame {
1382 frame
1383 .for_member(ast, self.try_construct, Some(self.type_), self.member)
1384 .in_aggregate()
1385 }
1386}
1387
1388fn member_table(
1389 ast: &Ast,
1390 type_: Id<TypeNode>,
1391 layout: &zerodds_ast_layout::TypeLayout,
1392) -> Vec<MemberSlot> {
1393 let all = ast.all_members_of(type_);
1394 let mut out = Vec::with_capacity(layout.members.len());
1395 for member in &layout.members {
1396 let mut found = None;
1397 for id in &all {
1398 let Ok(node) = ast.member(*id) else { continue };
1399 if node.node_id == member.member {
1400 found = Some((*id, node));
1401 break;
1402 }
1403 }
1404 let Some((id, node)) = found else { continue };
1405 let Some(member_type) = node.type_.resolved() else {
1406 continue;
1407 };
1408 out.push(MemberSlot {
1409 name: node.name.clone(),
1410 type_: member_type,
1411 optional: node.facets.is_optional(),
1412 member_id: member.member_id,
1413 header: member.header,
1414 member: id,
1415 try_construct: node.facets.xtypes.as_ref().and_then(|x| x.try_construct),
1416 });
1417 }
1418 out
1419}
1420
1421/// Where a positional aggregate's members stop, which is not always
1422/// where this reader's plan stops.
1423///
1424/// The half of appendable evolution a deployed system meets first: a new
1425/// subscriber reading an old publisher. §7.2.4.4.8, Table 19 —
1426/// "Each non-optional member in a T1 object that is not present in the
1427/// T2 object takes its default value."
1428#[derive(Debug, Clone, Copy)]
1429struct Tail {
1430 /// The delimiter's extent, when the type has one.
1431 end: Option<usize>,
1432 /// Whether the aggregate is `@appendable`.
1433 appendable: bool,
1434}
1435
1436impl Tail {
1437 /// Whether the writer's members have run out at this point.
1438 ///
1439 /// Two ways, and both are needed because §7.2.4.2 delimits an
1440 /// appendable type under encoding version 2 and not under version 1.
1441 ///
1442 /// - **The delimiter is used up.** `end` is `Some` for an
1443 /// `@appendable` type under XCDR2, and the DHEADER is exactly the
1444 /// thing that says how far the writer's members went.
1445 /// - **The payload is used up, in an `@appendable` type.** Under
1446 /// XCDR1 there is no delimiter, and all three vendors default the
1447 /// tail anyway when the bytes run out: measured 2026-08-09,
1448 /// `evo-appendable-tail` under `xcdr1`, where Cyclone, RTI and Fast
1449 /// DDS each hand back `c = 0` for a two-member writer read as a
1450 /// three-member type. Refusing there would mean this project could
1451 /// not receive from any of the three on an evolved XCDR1 topic.
1452 ///
1453 /// The second is deliberately `remaining() == 0` and not "fewer
1454 /// bytes than the member needs". An appendable type *nested* inside
1455 /// another still has the enclosing type's bytes behind it, so
1456 /// nothing here fires and the member is read out of them — which
1457 /// fails loudly on the next member, and is what Cyclone and RTI do
1458 /// with the same case (`evo-appendable-nested` under `xcdr1`, both
1459 /// refuse). Guessing there would take the outer type's bytes for a
1460 /// default and hand back a sample that looked right.
1461 ///
1462 /// `@final` gets neither: Table 19 requires the same set of member
1463 /// IDs on both sides, so a short stream is a type mismatch and not
1464 /// an evolution. Cyclone and RTI refuse it, Fast DDS defaults —
1465 /// `evo-final-tail` — and the clause is with the two.
1466 fn reached(self, r: &Reader<'_>) -> bool {
1467 match self.end {
1468 Some(end) => r.position() >= end,
1469 None => self.appendable && r.remaining() == 0,
1470 }
1471 }
1472}
1473
1474/// A `@final` or `@appendable` aggregate: the members in plan order.
1475///
1476/// Every member is where the plan says it is, including an `@optional`
1477/// one: §7.4.3.5.2 rule (20) puts a BOOLEAN `<is_present>` in front of
1478/// it under XCDR2, and §7.4.1.2 gives it a parameter header with a
1479/// declared length of zero under XCDR1. Both mark the gap, so nothing
1480/// here is guessed and nothing rewinds.
1481///
1482/// This used to read the header into a clone of the reader and keep it
1483/// only when the member id matched, on the reading that nothing marks an
1484/// absent optional member. That reading was wrong — see the two clauses
1485/// above and `vendor_bytes.md` §1, where all three implementations write
1486/// the marker — and it was wrong in the direction that returns a value:
1487/// a vendor's `00000000` was taken for a header that did not match, the
1488/// member was called absent, and the four bytes that followed were read
1489/// as the *next* member.
1490fn read_in_plan_order<V: Source + Sink>(
1491 r: &mut Reader<'_>,
1492 ast: &Ast,
1493 members: &[MemberSlot],
1494 frame: Frame,
1495 path: &str,
1496 tail: Tail,
1497) -> Result<Aggregate<V>, ProbeError> {
1498 let mut out = Aggregate::new();
1499 for (index, slot) in members.iter().enumerate() {
1500 let member_path = format!("{path}.{}", slot.name);
1501 if tail.reached(r) {
1502 defaults_from(ast, &members[index..], &mut out, path)?;
1503 break;
1504 }
1505 match slot.header {
1506 // Rule (18).
1507 MemberHeader::NoHeader => {
1508 out.insert(
1509 slot.name.clone(),
1510 read_type(r, ast, slot.type_, slot.frame(ast, frame), &member_path)?,
1511 );
1512 }
1513 // Rule (20). `read_bool` refuses anything but 0 and 1, so a
1514 // byte that is neither is a stream this type does not
1515 // describe rather than a presence flag to guess at.
1516 MemberHeader::OptionalFlag => {
1517 if wire(r.read_bool(), &member_path)? {
1518 out.insert(
1519 slot.name.clone(),
1520 read_type(r, ast, slot.type_, slot.frame(ast, frame), &member_path)?,
1521 );
1522 }
1523 }
1524 // Rule (19) and §7.4.1.2. The header is there either way and
1525 // a length of zero is what says the member is not.
1526 MemberHeader::OptionalCdr1 { .. } => {
1527 let header = wire(r.read_pid_as(ParameterRole::Optional), &member_path)?;
1528 if header.end || header.pid != slot.member_id {
1529 return Err(ProbeError::Mismatch {
1530 path: member_path,
1531 expected: format!(
1532 "a parameter header for member id {}, which §7.4.1.2 writes \
1533 whether or not the member has a value",
1534 slot.member_id
1535 ),
1536 found: if header.end {
1537 format!("PID_LIST_END at offset {}", r.position())
1538 } else {
1539 format!("member id {} at offset {}", header.pid, r.position())
1540 },
1541 });
1542 }
1543 if header.length > 0 {
1544 let at = r.position();
1545 out.insert(
1546 slot.name.clone(),
1547 read_type(r, ast, slot.type_, slot.frame(ast, frame), &member_path)?,
1548 );
1549 overran(r, at, header.length, &member_path)?;
1550 }
1551 wire(r.finish_pid(header), &member_path)?;
1552 }
1553 // The planner gives these two only to a member of a
1554 // `@mutable` type, which is read by one of the two functions
1555 // below. Reaching them here would mean the plan and the walk
1556 // disagree about the extensibility, and reading on would put
1557 // every member after this one at the wrong offset.
1558 MemberHeader::EmHeader { .. } | MemberHeader::PidCdr1 { .. } => {
1559 return Err(ProbeError::NoLayout {
1560 detail: format!(
1561 "{member_path}: an MMEMBER header in a type that is not `@mutable`"
1562 ),
1563 });
1564 }
1565 }
1566 }
1567 Ok(out)
1568}
1569
1570/// A `@mutable` aggregate under XCDR2: EMHEADERs until the delimiter's
1571/// extent is used up.
1572///
1573/// Driven by the headers and not by the plan, because that is what the
1574/// headers are for. A writer may order the members as it likes and may
1575/// leave any `@optional` one out; a reader that walked its own plan
1576/// instead would misread both.
1577fn read_member_headers<V: Source + Sink>(
1578 r: &mut Reader<'_>,
1579 ast: &Ast,
1580 members: &[MemberSlot],
1581 frame: Frame,
1582 path: &str,
1583 end: Option<usize>,
1584) -> Result<Aggregate<V>, ProbeError> {
1585 let Some(end) = end else {
1586 return Err(ProbeError::NoLayout {
1587 detail: format!("{path}: a mutable type under XCDR2 with no DHEADER planned"),
1588 });
1589 };
1590 let mut out = Aggregate::new();
1591 while r.position() < end {
1592 let at = r.position();
1593 let header = wire(r.read_emheader(), path)?;
1594 let mut slot = None;
1595 for candidate in members {
1596 if candidate.member_id == header.member_id {
1597 slot = Some(candidate);
1598 break;
1599 }
1600 }
1601 // Where the member ends. `extent` counts from where the reader
1602 // now is under every length code, including the three whose
1603 // NEXTINT is the member's own first word and was peeked rather
1604 // than consumed. §7.4.3.4.2.
1605 let ends_at = r.position().saturating_add(header.extent);
1606 let Some(slot) = slot else {
1607 // A member the model does not declare. `must_understand`
1608 // says which of the two things to do about it, and §7.4.3.5.3
1609 // says a reader that cannot skip it must not pretend it did.
1610 if header.must_understand {
1611 return Err(ProbeError::Mismatch {
1612 path: path.to_string(),
1613 expected: "a member the type declares".to_string(),
1614 found: format!(
1615 "member id {} with must_understand set, at offset {at}",
1616 header.member_id
1617 ),
1618 });
1619 }
1620 skip_to(r, ends_at);
1621 continue;
1622 };
1623 let member_path = format!("{path}.{}", slot.name);
1624 let value = read_type(r, ast, slot.type_, slot.frame(ast, frame), &member_path)?;
1625 if r.position() != ends_at {
1626 return Err(ProbeError::Mismatch {
1627 path: member_path,
1628 expected: format!(
1629 "the member to end at offset {ends_at}, as length code {} said",
1630 header.length_code
1631 ),
1632 found: format!("offset {}", r.position()),
1633 });
1634 }
1635 if out.insert(slot.name.clone(), value).is_some() {
1636 return Err(ProbeError::Mismatch {
1637 path: member_path,
1638 expected: "each member once".to_string(),
1639 found: "a second EMHEADER for the same member id".to_string(),
1640 });
1641 }
1642 }
1643 require_present(ast, members, &mut out, path)?;
1644 Ok(out)
1645}
1646
1647/// A `@mutable` aggregate under XCDR1: parameters until `PID_LIST_END`.
1648fn read_parameter_list<V: Source + Sink>(
1649 r: &mut Reader<'_>,
1650 ast: &Ast,
1651 members: &[MemberSlot],
1652 frame: Frame,
1653 path: &str,
1654) -> Result<Aggregate<V>, ProbeError> {
1655 let mut out = Aggregate::new();
1656 loop {
1657 if r.remaining() < 4 {
1658 return Err(ProbeError::Mismatch {
1659 path: path.to_string(),
1660 expected: "a PID_LIST_END sentinel".to_string(),
1661 found: format!("the stream ended at offset {}", r.position()),
1662 });
1663 }
1664 let header = wire(r.read_pid(), path)?;
1665 if header.end {
1666 break;
1667 }
1668 let at = r.position();
1669 // `PID_IGNORE` outranks everything below, including the
1670 // must-understand bit: §7.4.1.2.2 says "All consumers of this
1671 // Data Representation shall ignore parameters with this ID" and
1672 // states no exception. Until this ran, 0x3F03 was ignored only
1673 // by accident — by falling through the unknown-parameter skip —
1674 // and a peer that set the flag on one got the sample discarded.
1675 if header.ignore {
1676 skip_to(r, at.saturating_add(header.length));
1677 wire(r.finish_pid(header), path)?;
1678 continue;
1679 }
1680 let mut slot = None;
1681 // A vendor-private parameter (`FLAG_IMPL_EXTENSION`) and one
1682 // whose id is inside §7.4.1.2.2's reserved range are not members
1683 // of this type whatever their low bits say, so neither is looked
1684 // up. Reading them as members took a vendor's own payload for
1685 // the value of the member with the same fourteen low bits.
1686 if header.is_user_member() {
1687 for candidate in members {
1688 if candidate.member_id == header.pid {
1689 slot = Some(candidate);
1690 break;
1691 }
1692 }
1693 }
1694 // A parameter this type does not declare. PL_CDR1 carries a
1695 // length on every parameter, so skipping is always possible —
1696 // but §7.2.2.4.4.4.6 says a reader that does not understand a
1697 // must-understand member shall discard the whole sample, not
1698 // step over it. Skipping it silently was the XCDR1 half of M6;
1699 // the XCDR2 half in `read_member_headers` already refused.
1700 let Some(slot) = slot else {
1701 if header.must_understand {
1702 return Err(ProbeError::Mismatch {
1703 path: path.to_string(),
1704 expected: "a member the type declares".to_string(),
1705 found: format!(
1706 "member id {} with must_understand set, at offset {at}",
1707 header.pid
1708 ),
1709 });
1710 }
1711 skip_to(r, at.saturating_add(header.length));
1712 wire(r.finish_pid(header), path)?;
1713 continue;
1714 };
1715 let member_path = format!("{path}.{}", slot.name);
1716 let value = read_type(r, ast, slot.type_, slot.frame(ast, frame), &member_path)?;
1717 overran(r, at, header.length, &member_path)?;
1718 if out.insert(slot.name.clone(), value).is_some() {
1719 return Err(ProbeError::Mismatch {
1720 path: member_path,
1721 expected: "each member once".to_string(),
1722 found: "a second parameter for the same id".to_string(),
1723 });
1724 }
1725 wire(r.finish_pid(header), path)?;
1726 }
1727 require_present(ast, members, &mut out, path)?;
1728 Ok(out)
1729}
1730
1731/// A parameter's value may not be longer than the header said it was.
1732///
1733/// The other way round is legitimate and is why `Reader::finish_pid`
1734/// finds the next header by alignment rather than by adding the length:
1735/// RTI rounds a member's declared length up to the alignment where Fast
1736/// DDS writes the exact one, and `vendor_bytes.md` §2 records both for
1737/// the same `short`. Reading *past* the declared length is neither, and
1738/// it means the reader and the writer disagree about the type.
1739fn overran(r: &Reader<'_>, at: usize, length: usize, path: &str) -> Result<(), ProbeError> {
1740 if r.position() > at.saturating_add(length) {
1741 return Err(ProbeError::Mismatch {
1742 path: path.to_string(),
1743 expected: format!("a value within the {length} byte(s) the header declared"),
1744 found: format!("{} byte(s)", r.position().saturating_sub(at)),
1745 });
1746 }
1747 Ok(())
1748}
1749
1750/// The members the stream did not carry, each taking its default.
1751///
1752/// §7.4.1.2.3: "any mutable structure member's value may be omitted. For
1753/// non-optional members this may be due to the sender having a
1754/// compatible type that does not have that member. In such a case, if
1755/// the receiver's member is not optional, it logically takes its default
1756/// value. If the member is optional, it takes no value at all."
1757///
1758/// This is what `@mutable` is for, and until it existed a new subscriber
1759/// meeting an old publisher got `ProbeError::Missing` — a hard failure
1760/// on the one exchange the extensibility kind was designed to allow.
1761///
1762/// # `@must_understand` does not except a member from this
1763///
1764/// It reads as though it should. §7.2.4.4.8, Table 19, makes it a
1765/// condition of assignability that members "for which both optional is
1766/// false and must_understand is true in either T1 or T2 appear … in both
1767/// T1 and T2", so a stream that omits one did not come from an
1768/// assignable type.
1769///
1770/// It is not the decoder's condition to enforce, and the measurement
1771/// says so plainly. `evo-mutable-mustunderstand` on 2026-08-09: Cyclone,
1772/// RTI **and** Fast DDS each hand back `c = 0` for a receiver whose
1773/// `@id(3) @must_understand long c` the writer's type did not have, in
1774/// both encoding versions. All three, both ways round.
1775///
1776/// They are right. §7.2.2.4.4.4.6 defines the attribute for one
1777/// direction only — "a data consumer, upon identifying a member it does
1778/// not recognize, must discard the entire data sample" — which is M6 and
1779/// is enforced where an *unknown* id arrives. The reverse has no such
1780/// rule, and §7.4.1.2.3 states the default with no exception in it:
1781/// "if the receiver's member is not optional, it logically takes its
1782/// default value". Table 19's condition is a static type-compatibility
1783/// check, which in DDS belongs to discovery and
1784/// `TypeConsistencyEnforcementQosPolicy` (§7.6.3.3) — a layer above this
1785/// one, which sees both TypeObjects and can refuse the match before a
1786/// sample is ever written. A decoder that refused here would break the
1787/// exchange all three vendors allow, on nothing the bytes say.
1788///
1789/// The same holds for `@key`, which §7.2.2.4.4.4.8 makes must-understand
1790/// by definition.
1791fn require_present<V: Source + Sink>(
1792 ast: &Ast,
1793 members: &[MemberSlot],
1794 out: &mut Aggregate<V>,
1795 path: &str,
1796) -> Result<(), ProbeError> {
1797 defaults_from(ast, members, out, path)
1798}
1799
1800/// Fill in every member of `members` the stream left out.
1801///
1802/// Shared by the two `@mutable` readers, which know which ids arrived,
1803/// and by the `@appendable` reader, which knows where the writer's
1804/// members stopped and passes the tail from there.
1805fn defaults_from<V: Source + Sink>(
1806 ast: &Ast,
1807 members: &[MemberSlot],
1808 out: &mut Aggregate<V>,
1809 path: &str,
1810) -> Result<(), ProbeError> {
1811 for slot in members {
1812 if out.contains(&slot.name) {
1813 continue;
1814 }
1815 // "If the member is optional, it takes no value at all."
1816 // §7.2.2.4.4.4.7 is explicit that this is not a defaulted zero:
1817 // "Implementations shall not provide a default value in such a
1818 // case."
1819 if slot.optional {
1820 continue;
1821 }
1822 let member_path = format!("{path}.{}", slot.name);
1823 let node = ast
1824 .member(slot.member)
1825 .map_err(|error| ProbeError::NoLayout {
1826 detail: format!("{member_path}: {error}"),
1827 })?;
1828 out.insert(
1829 slot.name.clone(),
1830 crate::default::member(ast, node, slot.type_, &member_path)?,
1831 );
1832 }
1833 Ok(())
1834}
1835
1836/// Whether a framed member's id has to be the one the plan names.
1837#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1838enum MemberIdCheck {
1839 /// The stream's id must equal the plan's. Every read but one.
1840 Exact,
1841 /// Whatever id the stream carries is accepted, and the body behind
1842 /// it is read as the type the plan names.
1843 ///
1844 /// Reached from one place only:
1845 /// [`DefaultedDiscriminatorMember::StreamValue`] with a union whose
1846 /// discriminator was defaulted. The member the writer framed is the
1847 /// one the *writer's* discriminator selected, so its id is the
1848 /// writer's and there is nothing on this side to check it against —
1849 /// this reader's discriminator no longer names that case. Under
1850 /// [`DefaultedDiscriminatorMember::TypeDefault`] the member is never
1851 /// read at all, so the id never has to be believed.
1852 Streamed,
1853}
1854
1855impl MemberIdCheck {
1856 /// Whether the id is checked.
1857 const fn is_exact(self) -> bool {
1858 matches!(self, Self::Exact)
1859 }
1860}
1861
1862/// One `MMEMBER` of a `@mutable` type, read as its plan says.
1863///
1864/// The counterpart of `encode.rs::write_mmember`, and the same reason
1865/// for existing: rules (21) to (25) and rules (27) and (28) frame a
1866/// structure's members and a union's discriminator and selected member
1867/// identically, so one function reads both.
1868///
1869/// The id is checked rather than trusted, unless the caller hands in
1870/// [`MemberIdCheck::Streamed`]. A header for another member is not a
1871/// member this reader can skip past — the value behind it belongs to
1872/// something else, and reading on would take every following byte out
1873/// of the wrong place.
1874#[allow(clippy::too_many_arguments)]
1875fn read_mmember<V: Source + Sink>(
1876 r: &mut Reader<'_>,
1877 ast: &Ast,
1878 header: MemberHeader,
1879 member_id: u32,
1880 id_check: MemberIdCheck,
1881 type_: Id<TypeNode>,
1882 frame: Frame,
1883 path: &str,
1884) -> Result<V, ProbeError> {
1885 match header {
1886 MemberHeader::PidCdr1 { .. } => {
1887 let parameter = wire(r.read_pid(), path)?;
1888 if parameter.end || (id_check.is_exact() && parameter.pid != member_id) {
1889 return Err(ProbeError::Mismatch {
1890 path: path.to_string(),
1891 expected: format!("a parameter header for member id {member_id}"),
1892 found: if parameter.end {
1893 format!("PID_LIST_END at offset {}", r.position())
1894 } else {
1895 format!("member id {} at offset {}", parameter.pid, r.position())
1896 },
1897 });
1898 }
1899 let at = r.position();
1900 // A discarded member still has to leave the reader where the
1901 // next parameter starts. The caller may be able to carry on
1902 // — a union whose discriminator took its default has to step
1903 // over the member the writer selected — and a reader parked
1904 // mid-parameter would read the next one out of the middle of
1905 // this one. The framing is closed first and the refusal is
1906 // re-raised after.
1907 let value = match read_type(r, ast, type_, frame, path) {
1908 Ok(value) => value,
1909 Err(discarded @ ProbeError::Discarded { .. }) => {
1910 skip_to(r, at.saturating_add(parameter.length));
1911 wire(r.finish_pid(parameter), path)?;
1912 return Err(discarded);
1913 }
1914 Err(other) => return Err(other),
1915 };
1916 overran(r, at, parameter.length, path)?;
1917 wire(r.finish_pid(parameter), path)?;
1918 Ok(value)
1919 }
1920 MemberHeader::EmHeader { .. } => {
1921 let emheader = wire(r.read_emheader(), path)?;
1922 if id_check.is_exact() && emheader.member_id != member_id {
1923 return Err(ProbeError::Mismatch {
1924 path: path.to_string(),
1925 expected: format!("an EMHEADER for member id {member_id}"),
1926 found: format!(
1927 "member id {} at offset {}",
1928 emheader.member_id,
1929 r.position()
1930 ),
1931 });
1932 }
1933 let ends_at = r.position().saturating_add(emheader.extent);
1934 // As above: the extent is known, so a discarded member is
1935 // stepped over before the refusal goes up.
1936 let value = match read_type(r, ast, type_, frame, path) {
1937 Ok(value) => value,
1938 Err(discarded @ ProbeError::Discarded { .. }) => {
1939 skip_to(r, ends_at);
1940 return Err(discarded);
1941 }
1942 Err(other) => return Err(other),
1943 };
1944 if r.position() != ends_at {
1945 return Err(ProbeError::Mismatch {
1946 path: path.to_string(),
1947 expected: format!("the member to end at offset {ends_at}"),
1948 found: format!("offset {}", r.position()),
1949 });
1950 }
1951 Ok(value)
1952 }
1953 MemberHeader::NoHeader | MemberHeader::OptionalCdr1 { .. } | MemberHeader::OptionalFlag => {
1954 Err(ProbeError::NoLayout {
1955 detail: format!(
1956 "{path}: a `@mutable` member planned with a header that is not one"
1957 ),
1958 })
1959 }
1960 }
1961}
1962
1963/// Whether a union case takes a number of octets its own type fixes.
1964///
1965/// Read from one place: `DefaultedDiscriminatorMember::StreamValue` over
1966/// a union that states no extent of its own. A fixed-extent case is safe
1967/// to read out of octets the writer meant for a different member — it
1968/// takes what its type takes, and the reader ends at a position this
1969/// type decides — while an unbounded one would read its length out of
1970/// those octets and could run to the end of the buffer.
1971///
1972/// A plan that will not compute is not a bound either, so it answers
1973/// false and the member is stepped over.
1974fn case_extent_is_fixed(ast: &Ast, type_: Id<TypeNode>, frame: Frame) -> bool {
1975 let mut deps = DepCollector::new();
1976 plan(ast, type_, frame.key, &mut deps).is_ok_and(|layout| layout.extent.is_sized())
1977}
1978
1979/// A union: the discriminator, then the member its labels select.
1980///
1981/// A `@mutable` union frames both as `MMEMBER`s — rules (27) and (28) —
1982/// and the discriminator comes first, because a reader cannot know which
1983/// member is selected until it has read it. RTI and Fast DDS both write
1984/// it first; a peer that reordered the two would be unreadable by any of
1985/// the three, so the order is required here rather than searched for.
1986///
1987/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`, checked by
1988/// the `frame.within_depth` at the top of this function before any header
1989/// is read.
1990fn read_union<V: Source + Sink>(
1991 r: &mut Reader<'_>,
1992 ast: &Ast,
1993 type_: Id<TypeNode>,
1994 frame: Frame,
1995 path: &str,
1996) -> Result<V, ProbeError> {
1997 // As in `read_aggregate`: a union on a cycle nests a value just as
1998 // a structure does.
1999 frame.within_depth(path)?;
2000
2001 // As in `read_aggregate`: under `ElementPadding::Packed` a union is
2002 // its own alignment block.
2003 let packed = frame.key.element_padding == ElementPadding::Packed;
2004 let saved_origin = packed.then(|| r.begin_packed_element());
2005
2006 let mut deps = DepCollector::new();
2007 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2008 detail: format!("{error}"),
2009 })?;
2010 let end = if layout.needs_dheader {
2011 let length = wire(r.read_dheader(), path)?;
2012 Some(r.position().saturating_add(length))
2013 } else {
2014 None
2015 };
2016
2017 let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
2018 detail: format!("{error}"),
2019 })?;
2020 let TypeKind::Union { discriminator, .. } = &node.kind else {
2021 return Err(ProbeError::Unsupported {
2022 path: path.to_string(),
2023 kind: "a union whose kind changed under the probe",
2024 });
2025 };
2026 let discriminator = discriminator
2027 .resolved()
2028 .ok_or_else(|| ProbeError::NoLayout {
2029 detail: format!("{path}: the discriminator is unresolved"),
2030 })?;
2031
2032 let discriminant_path = format!("{path}._d");
2033 // §7.3.1.2.1.12.5 puts `@try_construct` on the discriminator itself:
2034 // `union U switch (@try_construct K)`. Table 19 admits only DEFAULT
2035 // there, and the frontend refuses TRIM, so the only two outcomes are
2036 // a discarded sample and the discriminator's Table 9 default.
2037 //
2038 // Read under DISCARD first, so that a discriminator this type cannot
2039 // construct comes back as an error rather than as a value: the
2040 // difference decides not only what the discriminator is but whether
2041 // the *member* the stream carries can be believed. §7.2.4.4.8's
2042 // UNION_TYPE row defaults the discriminator and then sets the member
2043 // T1 now selects to *its* default; it does not take the writer's
2044 // member, and it cannot — §7.2.4.1 rules out "any inspection of a
2045 // data producer's type definition", and without one there is no way
2046 // to know what type the writer's member had. Measured twice, on two
2047 // vendors: OpenDDS hands back `d=0 v=0` for §7.3.1.2.1.12.5's own
2048 // example, not the 42 the writer put in `e3_value`; and RTI Connext
2049 // 7.7.0, given a writer that sends VAL3 and a reader whose
2050 // discriminator defaults to VAL1, hands back `{"x1":0}` — it does not
2051 // read the writer's four octets as `x1`. RTI reaches that outcome
2052 // without the annotation at all: `rtiddsgen` reports `try_construct`
2053 // as ignored, and the shipped Extensible Types guide lists it among
2054 // the annotations the middleware ignores. Which makes it a witness
2055 // for the *default* behaviour §7.2.4.1 describes, not for a reading
2056 // of the annotation.
2057 //
2058 // The dds-xtypes suite expects the other answer — `{"x1":3}`, the
2059 // member kept. That disagreement is an axis rather than a verdict:
2060 // `DefaultedDiscriminatorMember::StreamValue` reads the writer's
2061 // octets as the member this type now selects, and
2062 // `DefaultedDiscriminatorMember::TypeDefault` — the default, which
2063 // is what RTI and OpenDDS hand back — steps over them. Of the four
2064 // implementations that can be made to run the case, three default
2065 // the member and only CoreDX 6.14 keeps it, that one on the strength
2066 // of its exclusion list rather than a measurement. Fast DDS 3.6.1
2067 // cannot express the case: it refuses annotations on union
2068 // discriminators outright, and ties enum assignability to the enum's
2069 // identity, so the suite's whole E1/E2 family never matches there.
2070 // A discriminator is a member position, not an element one.
2071 let discarding = Frame {
2072 is_discriminator: true,
2073 ..frame.resolved(TryConstruct::Discard).in_aggregate()
2074 };
2075 let raw = match layout.discriminator {
2076 None => read_type(r, ast, discriminator, discarding, &discriminant_path),
2077 Some(header) => read_mmember(
2078 r,
2079 ast,
2080 header,
2081 0,
2082 MemberIdCheck::Exact,
2083 discriminator,
2084 discarding,
2085 &discriminant_path,
2086 ),
2087 };
2088 let mut discriminator_defaulted = false;
2089 let discriminant = match raw {
2090 Ok(value) => value,
2091 Err(discarded @ ProbeError::Discarded { .. }) => {
2092 if ast.discriminator_try_construct(type_) != TryConstruct::UseDefault {
2093 return Err(discarded);
2094 }
2095 discriminator_defaulted = true;
2096 crate::default::of_type(ast, discriminator, &discriminant_path)?
2097 }
2098 Err(other) => return Err(other),
2099 };
2100 let selector = selector_of(ast, discriminator, &discriminant, &discriminant_path)?;
2101
2102 let mut out = Aggregate::new();
2103 out.insert("_d".to_string(), discriminant);
2104 // Whether the sentinel the parameter list ends with has been eaten
2105 // already, which happens where the writer's union selected no member
2106 // and this one's does.
2107 let mut sentinel_seen = false;
2108 let suppress_negative = union_negative_matches_nothing(ast, type_, frame.key.union_label);
2109 let selected =
2110 select_case(ast, type_, selector, suppress_negative).map_err(|_| ProbeError::Mismatch {
2111 path: path.to_string(),
2112 expected: String::from("a union whose case labels all evaluate"),
2113 found: String::from("a case label the model cannot evaluate"),
2114 })?;
2115 // The axis, and the one place it is read. Two conditions beyond the
2116 // axis value itself, and neither is a restriction of convenience:
2117 //
2118 // - a case to read the octets into. Where the defaulted
2119 // discriminator selects none, `StreamValue` has nothing to say and
2120 // the member is stepped over as under the default.
2121 // - a bound on how far the read can run. The octets are read as
2122 // *this* type's case, which is not the type the writer wrote, so
2123 // the length prefix of a `string`, `sequence` or `map` would be
2124 // taken out of octets that were never a length. A delimiter and a
2125 // sentinel each state where the union ends and every read inside
2126 // them is checked against it; where the union states neither — an
2127 // undelimited `@final` or `@appendable` union under XCDR1 — a
2128 // fixed-extent case is the bound, because it takes exactly as many
2129 // octets as its own type has and no number in the stream decides
2130 // how many. An unbounded case there has no bound at all, and is
2131 // stepped over instead, which is the refusal below.
2132 let keep_streamed_member = discriminator_defaulted
2133 && frame.key.defaulted_discriminator_member == DefaultedDiscriminatorMember::StreamValue
2134 && selected.as_ref().is_some_and(|case| {
2135 end.is_some() || layout.needs_sentinel || case_extent_is_fixed(ast, case.type_, frame)
2136 });
2137 if discriminator_defaulted && !keep_streamed_member {
2138 // The stream's member belongs to a case this type does not
2139 // have, and the reader has to get past it to whatever follows.
2140 // Two framings say where that is and one does not: a delimiter
2141 // states the extent, a PL_CDR1 parameter list ends at its
2142 // sentinel, and an undelimited `@final` or `@appendable` union
2143 // under XCDR1 states nothing at all. Guessing the member's
2144 // length in the third case would hand back a sample assembled
2145 // out of the wrong offsets.
2146 match (end, layout.needs_sentinel) {
2147 (Some(end), _) => skip_to(r, end),
2148 (None, true) => skip_parameter_list(r, path)?,
2149 (None, false) => {
2150 return Err(ProbeError::Mismatch {
2151 path: discriminant_path,
2152 expected: "a delimiter or a sentinel, so that a defaulted \
2153 discriminator can step over the member the writer \
2154 selected — or read it, under \
2155 `DefaultedDiscriminatorMember::StreamValue`"
2156 .to_string(),
2157 found: "an undelimited union, whose member extent nothing states".to_string(),
2158 });
2159 }
2160 }
2161 }
2162
2163 if let Some(case) = selected {
2164 let member_path = format!("{path}.{}", case.name);
2165 // The discriminator was defaulted, the reader is already past
2166 // the writer's member, and the case this type now selects had no
2167 // value in the stream: Table 19's "m1 is set to its default
2168 // value according to its type".
2169 if discriminator_defaulted && !keep_streamed_member {
2170 let value = crate::default::of_type(ast, case.type_, &member_path)?;
2171 out.insert(case.name, value);
2172 return Ok(V::aggregate(out));
2173 }
2174 // Under `StreamValue` the framed member carries the writer's
2175 // member id, which this reader has nothing to check against.
2176 let id_check = if keep_streamed_member {
2177 MemberIdCheck::Streamed
2178 } else {
2179 MemberIdCheck::Exact
2180 };
2181 let case_frame = frame
2182 .constructing(ast, case.try_construct, Some(case.type_))
2183 .in_aggregate();
2184 let value = match layout.members.iter().find(|plan| plan.member == case.node) {
2185 None => read_type(r, ast, case.type_, case_frame, &member_path)?,
2186 Some(plan) => {
2187 // §7.2.4.4.8, Table 19, the UNION_TYPE row: "If the
2188 // discriminator value does not select any member in O2,
2189 // then: If the discriminator value selects a member m1
2190 // in O1, then m1 is set to its default value according
2191 // to its type." Two versions of a union whose newer one
2192 // gave an existing discriminator value a case is exactly
2193 // that, and RTI is the witness — `evo-union-unselected`
2194 // on 2026-08-09 hands back `{"_d":2,"b":0}` in both
2195 // encoding versions.
2196 //
2197 // Which of the two the stream is saying is read off the
2198 // framing and never guessed: under XCDR2 the DHEADER
2199 // says the union is over, and under XCDR1 the sentinel
2200 // does.
2201 match union_member_frame(
2202 r,
2203 plan.header,
2204 plan.member_id,
2205 id_check,
2206 end,
2207 &member_path,
2208 )? {
2209 UnionMember::AbsentAtSentinel => {
2210 sentinel_seen = true;
2211 crate::default::of_type(ast, case.type_, &member_path)?
2212 }
2213 UnionMember::AbsentAtDelimiter => {
2214 crate::default::of_type(ast, case.type_, &member_path)?
2215 }
2216 UnionMember::Parameter(parameter) => {
2217 let at = r.position();
2218 let value = read_type(r, ast, case.type_, case_frame, &member_path)?;
2219 overran(r, at, parameter.length, &member_path)?;
2220 wire(r.finish_pid(parameter), &member_path)?;
2221 value
2222 }
2223 UnionMember::Framed => read_mmember(
2224 r,
2225 ast,
2226 plan.header,
2227 plan.member_id,
2228 id_check,
2229 case.type_,
2230 case_frame,
2231 &member_path,
2232 )?,
2233 }
2234 }
2235 };
2236 out.insert(case.name, value);
2237 }
2238
2239 // Rule (28): the parameter list ends at its sentinel, whether or not
2240 // a member was selected. A defaulted discriminator that stepped over
2241 // the writer's member is already at the delimiter's end, sentinel and
2242 // all; one that read it is not, and closes the list the ordinary way.
2243 if discriminator_defaulted && !keep_streamed_member {
2244 if let Some(saved) = saved_origin {
2245 r.finish_packed_element(saved);
2246 }
2247 return Ok(V::aggregate(out));
2248 }
2249 if layout.needs_sentinel && !sentinel_seen {
2250 let sentinel = wire(r.read_pid(), path)?;
2251 if !sentinel.end {
2252 return Err(ProbeError::Mismatch {
2253 path: path.to_string(),
2254 expected: "PID_LIST_END closing the union's parameter list".to_string(),
2255 found: format!("member id {} at offset {}", sentinel.pid, r.position()),
2256 });
2257 }
2258 }
2259
2260 if let Some(end) = end {
2261 if r.position() > end {
2262 return Err(ProbeError::Mismatch {
2263 path: path.to_string(),
2264 expected: format!("the union to end at offset {end}, as the DHEADER said"),
2265 found: format!("offset {}", r.position()),
2266 });
2267 }
2268 skip_to(r, end);
2269 }
2270 if let Some(saved) = saved_origin {
2271 r.finish_packed_element(saved);
2272 }
2273 Ok(V::aggregate(out))
2274}
2275
2276/// Step over the rest of a PL_CDR1 parameter list, to its sentinel.
2277///
2278/// Each parameter states its own length, so this walks and never
2279/// guesses. A stream that runs out before the sentinel is malformed and
2280/// says so rather than stopping quietly at the end of the buffer.
2281///
2282/// # Errors
2283/// [`ProbeError::Mismatch`] where no sentinel arrives.
2284fn skip_parameter_list(r: &mut Reader<'_>, path: &str) -> Result<(), ProbeError> {
2285 while r.remaining() >= 4 {
2286 let header = wire(r.read_pid(), path)?;
2287 if header.end {
2288 return Ok(());
2289 }
2290 let at = r.position();
2291 skip_to(r, at.saturating_add(header.length));
2292 wire(r.finish_pid(header), path)?;
2293 }
2294 Err(ProbeError::Mismatch {
2295 path: path.to_string(),
2296 expected: "PID_LIST_END closing the union's parameter list".to_string(),
2297 found: format!("the stream ended at offset {}", r.position()),
2298 })
2299}
2300
2301/// What the stream says about the member this union's discriminator
2302/// selects.
2303#[derive(Debug)]
2304enum UnionMember {
2305 /// A `PID_LIST_END` stood where the member's parameter would be, and
2306 /// has been read.
2307 AbsentAtSentinel,
2308 /// The delimiter's extent was used up before the member's EMHEADER.
2309 AbsentAtDelimiter,
2310 /// The member's parameter header, already read.
2311 Parameter(zerodds_wire::reader::PidRead),
2312 /// The member is framed as the plan says and nothing has been read.
2313 Framed,
2314}
2315
2316/// Read as far as it takes to tell whether the member is there.
2317///
2318/// Only ever answered from the framing, never from a guess. A `@final`
2319/// union has neither a delimiter nor a sentinel, so nothing here fires —
2320/// and Table 19 requires two final unions to have an identical set of
2321/// labels anyway, which makes a stream missing the member a type
2322/// mismatch rather than an evolution.
2323fn union_member_frame(
2324 r: &mut Reader<'_>,
2325 header: MemberHeader,
2326 member_id: u32,
2327 id_check: MemberIdCheck,
2328 end: Option<usize>,
2329 path: &str,
2330) -> Result<UnionMember, ProbeError> {
2331 match header {
2332 // Rule (28): the list runs to its sentinel, and a sentinel where
2333 // the member's parameter would be is the writer saying it wrote
2334 // none. The header is consumed either way, because a reader that
2335 // could put one back would need a mark this crate does not
2336 // have — and the two outcomes each use what was read.
2337 MemberHeader::PidCdr1 { .. } => {
2338 let parameter = wire(r.read_pid(), path)?;
2339 if parameter.end {
2340 return Ok(UnionMember::AbsentAtSentinel);
2341 }
2342 if id_check.is_exact() && parameter.pid != member_id {
2343 return Err(ProbeError::Mismatch {
2344 path: path.to_string(),
2345 expected: format!("a parameter header for member id {member_id}"),
2346 found: format!("member id {} at offset {}", parameter.pid, r.position()),
2347 });
2348 }
2349 Ok(UnionMember::Parameter(parameter))
2350 }
2351 // Rule (27) under a DHEADER: the extent is what says the union is
2352 // over.
2353 MemberHeader::EmHeader { .. } if end.is_some_and(|end| r.position() >= end) => {
2354 Ok(UnionMember::AbsentAtDelimiter)
2355 }
2356 _ => Ok(UnionMember::Framed),
2357 }
2358}
2359
2360/// The integer a discriminator selects a case by.
2361///
2362/// IDL 4.2 §7.4.1.4.4.4.2 allows an integer, a `char`, a `boolean` and
2363/// an enumeration; all four reduce to an integer, and for the
2364/// enumeration it is the enumerator's value rather than its ordinal.
2365///
2366/// zerodds-lint: recursion-depth 64 — one frame per typedef hop,
2367/// `contract::MAX_ALIAS_HOPS`. The typedef arm is the only recursive one;
2368/// a chain that returns to itself never reaches here, because every caller
2369/// has planned the type first and `zerodds_ast_layout::plan` refuses a
2370/// graph deeper than its own `MAX_DEPTH`.
2371pub(crate) fn selector_of<V: Source>(
2372 ast: &Ast,
2373 discriminator: Id<TypeNode>,
2374 value: &V,
2375 path: &str,
2376) -> Result<i128, ProbeError> {
2377 let node = ast
2378 .type_(discriminator)
2379 .map_err(|error| ProbeError::NoLayout {
2380 detail: format!("{error}"),
2381 })?;
2382 match &node.kind {
2383 TypeKind::Alias { aliased } => {
2384 let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
2385 detail: format!("{path}: the typedef target is unresolved"),
2386 })?;
2387 selector_of(ast, target, value, path)
2388 }
2389 TypeKind::Enumeration { .. } => {
2390 let name = value.as_text().ok_or_else(|| ProbeError::Mismatch {
2391 path: path.to_string(),
2392 expected: "an enumerator name".to_string(),
2393 found: "something else".to_string(),
2394 })?;
2395 for id in ast.enumerators_of(discriminator) {
2396 let Ok(enumerator) = ast.enumerator(*id) else {
2397 continue;
2398 };
2399 if enumerator.name == name {
2400 if let Some(number) = ast.effective_enumerator_value(*id) {
2401 return Ok(number);
2402 }
2403 }
2404 }
2405 Err(ProbeError::Unknown {
2406 path: path.to_string(),
2407 declared: Vec::new(),
2408 })
2409 }
2410 TypeKind::Primitive(PrimitiveKind::Boolean) => {
2411 let flag = value.as_bool().ok_or_else(|| ProbeError::Mismatch {
2412 path: path.to_string(),
2413 expected: "true or false".to_string(),
2414 found: "something else".to_string(),
2415 })?;
2416 Ok(i128::from(flag))
2417 }
2418 // A bitmask discriminator, which DDS-XTypes 1.3 §7.5.2.6 admits
2419 // in XML (`<discriminator type="nonBasic" nonBasicTypeName=
2420 // "B_32"/>`) and IDL 4.2 rule (50) does not admit at all. Its
2421 // value is the set of bit names `read_bitmask` handed back, and
2422 // the label it is compared against is an integer, so the set is
2423 // packed back into the anchor the same way `write_bitmask` packs
2424 // it. Every position is below the bound — `bit_names` refuses
2425 // one that is not, per §7.4.1.1.3.2 — so the shift is in range
2426 // and the result is non-negative in `i128`, which keeps an
2427 // unsigned label of a 64-bit bitmask comparable.
2428 TypeKind::Bitmask { .. } => {
2429 let mut bits: u64 = 0;
2430 for (_, position) in crate::contract::bit_names(ast, discriminator, value, path)? {
2431 bits |= 1u64 << position;
2432 }
2433 Ok(i128::from(bits))
2434 }
2435 TypeKind::Primitive(PrimitiveKind::Char | PrimitiveKind::WChar) => {
2436 let text = value.as_text().ok_or_else(|| ProbeError::Mismatch {
2437 path: path.to_string(),
2438 expected: "a one-character string".to_string(),
2439 found: "something else".to_string(),
2440 })?;
2441 match text.chars().next() {
2442 Some(ch) => Ok(i128::from(u32::from(ch))),
2443 None => Err(ProbeError::Mismatch {
2444 path: path.to_string(),
2445 expected: "exactly one character".to_string(),
2446 found: "an empty string".to_string(),
2447 }),
2448 }
2449 }
2450 _ => integer_of(value, path),
2451 }
2452}
2453
2454/// The case the discriminator selects, or `default:`, or nothing.
2455///
2456/// Nothing is a legal outcome: a union with no `default:` and a
2457/// discriminator matching no label carries the discriminator and no
2458/// member. `union-default-unselected` in the corpus is that sample.
2459///
2460/// # Errors
2461/// [`contract::UnevaluatedLabel`] for a label the model cannot
2462/// evaluate. A skipped label matches nothing, so the reader would fall
2463/// to `default:` or to no member at all and hand back a branch the
2464/// stream does not carry — the shape the negative-label guard below
2465/// already names, arrived at by a different route.
2466///
2467/// Spelled out rather than naming the guard's identifier:
2468/// `union_label_match.rs::the_sites_go_through_the_crossing` counts the
2469/// occurrences of that identifier per file to check that every
2470/// selection site takes the axis, and a mention in prose would inflate
2471/// the count and let a removed *use* hide behind a comment.
2472pub(crate) fn select_case(
2473 ast: &Ast,
2474 type_: Id<TypeNode>,
2475 selector: i128,
2476 suppress_negative: bool,
2477) -> Result<Option<crate::contract::Case>, crate::contract::UnevaluatedLabel> {
2478 let mut fallback = None;
2479 for id in ast.cases_of(type_) {
2480 let Ok(node) = ast.case(*id) else { continue };
2481 let Ok(member) = ast.member(node.member) else {
2482 continue;
2483 };
2484 let Some(member_type) = member.type_.resolved() else {
2485 continue;
2486 };
2487 let case = crate::contract::Case {
2488 node: member.node_id,
2489 name: member.name.clone(),
2490 type_: member_type,
2491 try_construct: member.facets.xtypes.as_ref().and_then(|x| x.try_construct),
2492 labels: node.labels.clone(),
2493 };
2494 for (index, label) in node.labels.iter().enumerate() {
2495 match label.expr() {
2496 Some(expr) => {
2497 // Reported and not skipped, for the reason the
2498 // negative-label guard below gives: a label
2499 // that is passed over matches nothing, and the read
2500 // hands back `default:` or no member at all while
2501 // the stream carries this branch's octets. The
2502 // writer refuses the same construct in
2503 // `contract::union_selection`.
2504 let value = ast
2505 .eval_integral(expr)
2506 .map_err(|_| crate::contract::UnevaluatedLabel { case: index })?;
2507 // The setting reaches the reader as well as the
2508 // writer. A reader that matched a label its own
2509 // writer suppresses would hand back the branch the
2510 // stream does not carry, which is the shape this
2511 // project calls a silent wrong value.
2512 if suppress_negative && value < 0 {
2513 continue;
2514 }
2515 if value == selector {
2516 return Ok(Some(case));
2517 }
2518 }
2519 None => fallback = Some(case.clone()),
2520 }
2521 }
2522 }
2523 Ok(fallback)
2524}
2525
2526/// An enumeration, read as the integer its bit bound calls for.
2527///
2528/// Matched on the bit pattern and not on the value, because the pattern
2529/// is what a holder narrower than the value's range leaves: Cyclone
2530/// takes `@value(200)` under `@bit_bound(8)` and writes `c8`, which read
2531/// as a signed `Int8` is `-56`. Both readings name the same enumerator,
2532/// and only the pattern says so. `vendor_bytes.md`, `enum_bb8_200`.
2533fn read_enumeration<V: Source + Sink>(
2534 r: &mut Reader<'_>,
2535 ast: &Ast,
2536 type_: Id<TypeNode>,
2537 frame: Frame,
2538 path: &str,
2539) -> Result<V, ProbeError> {
2540 // The reader takes the same axis the writer took: this project's
2541 // rule is that a reader is given the settings rather than left to
2542 // guess the form. `zerodds_ast_layout::enumeration_bit_bound`.
2543 let bits = enumeration_bit_bound(ast, type_, frame.key.enum_holder);
2544 let pattern = match bits {
2545 0 => {
2546 return Err(ProbeError::Mismatch {
2547 path: path.to_string(),
2548 expected: "an @bit_bound between 1 and 32".to_string(),
2549 found: "0".to_string(),
2550 });
2551 }
2552 1..=8 => u32::from(wire(r.read_u8(), path)?),
2553 9..=16 => u32::from(wire(r.read_u16(), path)?),
2554 17..=32 => wire(r.read_u32(), path)?,
2555 other => {
2556 return Err(ProbeError::Mismatch {
2557 path: path.to_string(),
2558 expected: "an @bit_bound between 1 and 32, per §7.3.1.2.1.5".to_string(),
2559 found: format!("{other}"),
2560 });
2561 }
2562 };
2563
2564 let mut declared = Vec::new();
2565 for id in ast.enumerators_of(type_) {
2566 let Ok(node) = ast.enumerator(*id) else {
2567 continue;
2568 };
2569 declared.push(node.name.clone());
2570 // The reader takes the same axis the writer took, through the
2571 // same crossing: a peer that numbers its enumerators by position
2572 // puts the position on the wire, and a reader comparing declared
2573 // values would name a different enumerator or none.
2574 // `zerodds_ast_layout::enumerator_wire_value`.
2575 let Some(value) = enumerator_wire_value(ast, *id, frame.key.enumerator_value) else {
2576 continue;
2577 };
2578 if enumerator_pattern(value, bits) == Some(pattern) {
2579 return Ok(V::enumerator(node.name.clone()));
2580 }
2581 }
2582 // A pattern no enumerator has cannot construct an object of this
2583 // type — Table 18: "If the name or value of the T2 object does not
2584 // exist in T1, the object cannot construct any object of type T1" —
2585 // so it is the site's `try_construct` that decides, not this
2586 // function. Handing the number back was never an option: the
2587 // canonical form of an enumeration is a name and there is no name
2588 // for this.
2589 //
2590 // TRIM has no meaning on an enumeration (Table 11), and the frontend
2591 // refuses to write it there, so `narrowed` reports it rather than
2592 // inventing a nearest enumerator.
2593 let found = format!("{pattern:#x}, which names no enumerator; the type declares {declared:?}");
2594 narrowed(
2595 frame.try_construct,
2596 None,
2597 || frame.default_value(ast, type_, path),
2598 path,
2599 "an enumerator this type declares",
2600 &found,
2601 )
2602}
2603
2604/// The bit pattern an enumerator's value has in its holder.
2605///
2606/// The same rule `encode.rs` writes by, and deliberately a second copy
2607/// of it rather than a call into the writer: the two directions are
2608/// compared against each other, and a shared helper would make them
2609/// agree about a mistake.
2610fn enumerator_pattern(value: i128, bits: u16) -> Option<u32> {
2611 if bits == 0 || bits > 32 {
2612 return None;
2613 }
2614 let width = i128::from(bits);
2615 if value < -(1i128 << (width - 1)) || value > (1i128 << width) - 1 {
2616 return None;
2617 }
2618 let mask = (1u128 << width) - 1;
2619 #[allow(clippy::cast_sign_loss)]
2620 u32::try_from((value as u128) & mask).ok()
2621}
2622
2623/// A bitmask, read as the set bits' names in declaration order.
2624fn read_bitmask<V: Sink>(
2625 r: &mut Reader<'_>,
2626 ast: &Ast,
2627 type_: Id<TypeNode>,
2628 refuse_undeclared: bool,
2629 path: &str,
2630) -> Result<V, ProbeError> {
2631 let bound = ast.effective_bit_bound(type_);
2632 let bits = match bound {
2633 1..=8 => u64::from(wire(r.read_u8(), path)?),
2634 9..=16 => u64::from(wire(r.read_u16(), path)?),
2635 17..=32 => u64::from(wire(r.read_u32(), path)?),
2636 33..=64 => wire(r.read_u64(), path)?,
2637 other => {
2638 return Err(ProbeError::Mismatch {
2639 path: path.to_string(),
2640 expected: "an @bit_bound between 1 and 64, as Table 33 tabulates".to_string(),
2641 found: format!("{other}"),
2642 });
2643 }
2644 };
2645
2646 let mut names = Vec::new();
2647 let mut declared: u64 = 0;
2648 for id in ast.bits_of(type_) {
2649 let Ok(node) = ast.bit_value(*id) else {
2650 continue;
2651 };
2652 let Some(position) = ast.effective_bit_position(*id) else {
2653 continue;
2654 };
2655 if position >= bound {
2656 continue;
2657 }
2658 let mask = 1u64 << position;
2659 declared |= mask;
2660 if bits & mask != 0 {
2661 names.push(node.name.clone());
2662 }
2663 }
2664 // A union is selected by its discriminator, and a discriminator with a
2665 // bit left out is another value: it would select another case, or
2666 // none, with nothing saying so. The bit is named, with the type.
2667 if refuse_undeclared {
2668 let width_mask = if bound >= 64 {
2669 u64::MAX
2670 } else {
2671 (1u64 << bound) - 1
2672 };
2673 let undeclared = bits & width_mask & !declared;
2674 if undeclared != 0 {
2675 return Err(ProbeError::Mismatch {
2676 path: path.to_string(),
2677 expected: format!(
2678 "only the bits `{}` declares as a union discriminator",
2679 ast.type_(type_)
2680 .map_or("<bitmask>", |node| node.name.as_str())
2681 ),
2682 found: format!(
2683 "bit {} set, which no flag of the bitmask names",
2684 undeclared.trailing_zeros()
2685 ),
2686 });
2687 }
2688 }
2689 // A bit the type does not name is not read back. §7.4.1.1.3.2: "the
2690 // states of the remaining serialized bits are not specified, and
2691 // those bits are not considered to be part of the bitmask", and
2692 // Table 18: "Preserve bit values by index for all bits identified in
2693 // both T1 and T2". A writer's bit that this type has no name for is
2694 // a bit the types do not share.
2695 Ok(V::bit_names(names))
2696}
2697
2698/// A bitset, unpacked out of the anchor the planner sized.
2699///
2700/// An inherited bitset reads the whole chain, base-first, because that
2701/// is where the writer put it; see `write_bitset` for the measurement.
2702fn read_bitset<V: Sink>(
2703 r: &mut Reader<'_>,
2704 ast: &Ast,
2705 type_: Id<TypeNode>,
2706 frame: Frame,
2707 path: &str,
2708) -> Result<V, ProbeError> {
2709 let fields = ast.all_bitfields_of(type_);
2710 let mut deps = DepCollector::new();
2711 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2712 detail: format!("{error}"),
2713 })?;
2714 let Extent::Sized(anchor) = layout.extent else {
2715 return Err(ProbeError::Unsupported {
2716 path: path.to_string(),
2717 kind: "a bitset whose anchor the planner does not size",
2718 });
2719 };
2720 let bits = match anchor {
2721 1 => u64::from(wire(r.read_u8(), path)?),
2722 2 => u64::from(wire(r.read_u16(), path)?),
2723 4 => u64::from(wire(r.read_u32(), path)?),
2724 8 => wire(r.read_u64(), path)?,
2725 other => {
2726 return Err(ProbeError::Unsupported {
2727 path: path.to_string(),
2728 kind: match other {
2729 0 => "a bitset with no anchor",
2730 _ => "a bitset whose anchor is not one, two, four or eight bytes",
2731 },
2732 });
2733 }
2734 };
2735
2736 let mut out = Vec::new();
2737 let mut position: u32 = 0;
2738 for id in &fields {
2739 let Ok(field) = ast.bitfield(*id) else {
2740 continue;
2741 };
2742 let width = u32::from(field.bits);
2743 if position.saturating_add(width) > 64 {
2744 return Err(ProbeError::Unsupported {
2745 path: path.to_string(),
2746 kind: "a bitset wider than the sixty-four bits an anchor holds",
2747 });
2748 }
2749 if let Some(name) = field.name.as_ref() {
2750 let mask = if width >= 64 {
2751 u64::MAX
2752 } else {
2753 (1u64 << width) - 1
2754 };
2755 let raw = (bits >> position) & mask;
2756 out.push((name.clone(), i128::from(raw)));
2757 }
2758 position += width;
2759 }
2760 V::bitfields(out, path)
2761}
2762
2763/// A map: the entries, in the shape §7.4.1.1.4 says a map has.
2764///
2765/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. The
2766/// entries go back to `read_type`, whose aggregate arms count and refuse.
2767#[allow(clippy::too_many_arguments)]
2768fn read_map<V: Source + Sink>(
2769 r: &mut Reader<'_>,
2770 ast: &Ast,
2771 type_: Id<TypeNode>,
2772 key_type: &Ref<TypeNode>,
2773 value_type: &Ref<TypeNode>,
2774 bound: &Bound,
2775 frame: Frame,
2776 path: &str,
2777) -> Result<V, ProbeError> {
2778 let key_type = key_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2779 detail: format!("{path}: the map key type is unresolved"),
2780 })?;
2781 let value_type = value_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2782 detail: format!("{path}: the map value type is unresolved"),
2783 })?;
2784
2785 let mut deps = DepCollector::new();
2786 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2787 detail: format!("{error}"),
2788 })?;
2789 let end = if layout.needs_dheader {
2790 let length = wire(r.read_dheader(), path)?;
2791 Some(r.position().saturating_add(length))
2792 } else {
2793 None
2794 };
2795 // Under `Rule15Omits` a delimited map states no count, so the extent
2796 // is the only thing that says where the entries stop. Under the
2797 // default the count is there whether or not a delimiter is.
2798 let counted = end.is_none() || frame.map_length == MapLengthPolicy::SequenceEquivalent;
2799 let declared = if counted {
2800 Some(wire(r.read_u32(), path)? as usize)
2801 } else {
2802 None
2803 };
2804
2805 let mut entries = Vec::new();
2806 let mut index = 0usize;
2807 loop {
2808 match (declared, end) {
2809 (Some(count), _) if index >= count => break,
2810 (None, Some(end)) if r.position() >= end => break,
2811 (None, None) => break,
2812 _ => {}
2813 }
2814 let entry_path = format!("{path}[{index}]");
2815 // The key's behaviour and the value's are separate slots, and
2816 // Table 17 gives them different powers: a key that cannot be
2817 // constructed "fails the whole map" with no escape, while a
2818 // value takes the value slot's behaviour. `idl-lower` refuses
2819 // anything but DISCARD on a key, so `key_try_construct` is the
2820 // default here and the query is what says so.
2821 let key = read_type(
2822 r,
2823 ast,
2824 key_type,
2825 frame.resolved(ast.key_try_construct(type_)).in_collection(),
2826 &format!("{entry_path}.key"),
2827 )?;
2828 let value = read_type(
2829 r,
2830 ast,
2831 value_type,
2832 frame
2833 .resolved(ast.element_try_construct(type_, None))
2834 .in_collection(),
2835 &format!("{entry_path}.value"),
2836 )?;
2837 entries.push(V::map_entry(key, value));
2838 index += 1;
2839 }
2840 // The entries are all read before the bound is applied, for the
2841 // reason a sequence's are: stopping early would leave the rest in
2842 // the stream. The delimiter is then skipped to whether or not this
2843 // map fits, so a discarded map still leaves the reader where the
2844 // next member starts — the caller may be reading it under
2845 // USE_DEFAULT.
2846 let limit = bound_value(ast, bound);
2847 let over_bound = limit.is_some_and(|limit| entries.len() as i128 > limit);
2848 if let Some(end) = end {
2849 skip_to(r, end);
2850 }
2851 if over_bound {
2852 // Sorted first: a trim keeps "the order of the ... elements in
2853 // the sequence or map", and a map's order is the canonical one
2854 // this probe puts it in, not the wire's.
2855 entries.sort_by_key(canonical::text);
2856 return fit_collection(ast, entries, limit, type_, frame, path, "entries");
2857 }
2858 // Sorted by key, for the reason `canonical::canonicalise` sorts:
2859 // §7.4.1.1.4 says nothing about entry order, `fastddsgen` maps a
2860 // `map` to `std::map` and so reorders one, and the canonical form a
2861 // pairwise run compares in has to be the same on both sides of the
2862 // comparison or every map case fails on the container choice.
2863 entries.sort_by_key(canonical::text);
2864 Ok(V::collection(entries))
2865}
2866
2867/// A `wstring`, in the form [`WstringLengthPolicy`] names.
2868///
2869/// The default is XTypes 1.3 §7.4.1.1.2 — a byte count, "twice the
2870/// number of characters in the string", no terminator and no byte-order
2871/// mark to strip. Two of the three measured implementations write
2872/// something else, and the policy's own documentation carries the bytes
2873/// and the argument for reading one form rather than sniffing.
2874///
2875/// # Nothing here looks at the length to decide which form it is
2876///
2877/// The length is the field a hostile peer controls, and the forms are
2878/// not separable anyway — a Fast DDS `"ab"` followed by a `long` is
2879/// byte-identical to a specification `"a"` followed by the same `long`.
2880/// So the configured form is read, and a length that form cannot have
2881/// produced is refused with the form it *does* fit named in the error,
2882/// which is a diagnosis after the refusal rather than a parse decided
2883/// by the stream.
2884/// The layout crate's name for a byte-layer position.
2885///
2886/// The two enumerations are deliberately separate — `zerodds-wire`
2887/// knows nothing about a dialect — and this is the one place the walk
2888/// crosses between them. A `match` and not a cast: a position added to
2889/// either side has to be given a meaning here rather than silently
2890/// becoming `Member`, which is precisely how the framing defect
2891/// survived five emitters.
2892const fn layout_position(position: WcharPosition) -> WirePosition {
2893 match position {
2894 WcharPosition::Member => WirePosition::Member,
2895 WcharPosition::Element => WirePosition::Element,
2896 WcharPosition::Sentinel => WirePosition::Sentinel,
2897 WcharPosition::ExtendedMarker => WirePosition::ExtendedMarker,
2898 }
2899}
2900
2901fn read_wstring(
2902 r: &mut Reader<'_>,
2903 policy: WstringLengthPolicy,
2904 order: WstringUnitOrder,
2905 position: WirePosition,
2906 path: &str,
2907) -> Result<String, ProbeError> {
2908 let declared = wire(r.read_u32(), path)? as usize;
2909 let width = policy.character_width(position);
2910 // The three coordinates, not a list of named forms. C1 says whether
2911 // the count is of units or of the octets they occupy, C3 says what
2912 // an octet count divides by, and C2 is handled after the run
2913 // because a terminator occupies a unit on the wire whichever way it
2914 // is counted.
2915 let mut units = if matches!(policy.counts_at(position), WstringLengthCounts::Characters) {
2916 read_wstring_units(r, declared, width, order, path)?
2917 } else {
2918 // A count the width does not divide is a form mismatch and not
2919 // a short read: rounding it down would leave the remainder in
2920 // the stream and put every later member at the wrong offset.
2921 if declared % width != 0 {
2922 return Err(wstring_form_mismatch(policy, declared, path));
2923 }
2924 read_wstring_units(r, declared / width, width, order, path)?
2925 };
2926 if matches!(policy.terminator_at(position), WstringTerminator::Present) {
2927 // The count includes the NUL, so a count of zero is a frame
2928 // that does not even hold its own terminator.
2929 if declared < 1 {
2930 return Err(wstring_form_mismatch(policy, declared, path));
2931 }
2932 let terminator = units.pop();
2933 if terminator != Some(0) {
2934 return Err(ProbeError::Mismatch {
2935 path: path.to_string(),
2936 expected: "a NUL character ending the wstring, as its count includes one"
2937 .to_string(),
2938 found: format!("{:04x}", terminator.unwrap_or_default()),
2939 });
2940 }
2941 }
2942 // A NUL inside the text is the narrow string's problem in wide
2943 // form: every C peer stops at it, so the value this reader returns
2944 // and the value the peer returns differ for bytes both accepted.
2945 // Only where a terminator is expected — a cell without one is
2946 // entitled to carry U+0000 as a character.
2947 if matches!(policy.terminator_at(position), WstringTerminator::Present) {
2948 if let Some(offset) = units.iter().position(|unit| *unit == 0) {
2949 return Err(ProbeError::Mismatch {
2950 path: path.to_string(),
2951 expected: "no NUL inside a wstring a C peer would stop at".to_string(),
2952 found: format!("a NUL at character {offset} of {}", units.len()),
2953 });
2954 }
2955 }
2956
2957 // The byte-order mark is not a character of the string, and under
2958 // `ByteOrderMark` it also decides the order of everything behind it
2959 // — which `read_wstring_units` could not know until it had read it.
2960 // CORBA §9.3.1.6: `FEFF` in the stream's order means the units
2961 // follow in the stream's order, `FFFE` means they are the other way
2962 // round, and a `wstring` with no mark at all is big-endian. That
2963 // last rule is what makes this variant read a JacORB peer as well as
2964 // an omniORB one.
2965 if order.has_mark() {
2966 strip_byte_order_mark(&mut units);
2967 }
2968
2969 // An unpaired surrogate is refused rather than replaced: U+FFFD in
2970 // its place would be a character the sender never had.
2971 //
2972 // It catches less than it looks. Fast CDR's truncation of `U+1D11E`
2973 // is `U+D11E`, a Hangul syllable and well-formed UTF-16, so the
2974 // wrong character arrives with nothing to object to. Only a code
2975 // point whose low sixteen bits land in `D800..=DFFF` fails here.
2976 String::from_utf16(&units).map_err(|_| ProbeError::Mismatch {
2977 path: path.to_string(),
2978 expected: "well-formed UTF-16".to_string(),
2979 found: "an unpaired surrogate".to_string(),
2980 })
2981}
2982
2983/// A `wstring` whose characters arrive as their own UTF-8 octets.
2984///
2985/// The mirror of `encode::write_wstring_utf8` and of
2986/// [`WstringCodeUnits::Utf8`]: the declared length is the UTF-8 octet
2987/// count, and the terminator, when the cell carries one, is the last of
2988/// those octets. A count the stream cannot hold is refused before a byte
2989/// is read, for the reason `read_wstring` gives.
2990fn read_wstring_utf8(
2991 r: &mut Reader<'_>,
2992 policy: WstringLengthPolicy,
2993 position: WirePosition,
2994 path: &str,
2995) -> Result<String, ProbeError> {
2996 let declared = wire(r.read_u32(), path)? as usize;
2997 if declared > r.remaining() {
2998 return Err(ProbeError::Mismatch {
2999 path: path.to_string(),
3000 expected: "a wstring length the stream can hold".to_string(),
3001 found: format!("{declared} byte(s) with {} left", r.remaining()),
3002 });
3003 }
3004 let mut bytes = Vec::with_capacity(declared);
3005 for _ in 0..declared {
3006 bytes.push(wire(r.read_u8(), path)?);
3007 }
3008 if matches!(policy.terminator_at(position), WstringTerminator::Present) {
3009 if bytes.pop() != Some(0) {
3010 return Err(ProbeError::Mismatch {
3011 path: path.to_string(),
3012 expected: "a NUL octet ending the wstring, as its count includes one".to_string(),
3013 found: "a byte that is not zero".to_string(),
3014 });
3015 }
3016 if bytes.contains(&0) {
3017 return Err(ProbeError::Mismatch {
3018 path: path.to_string(),
3019 expected: "no NUL inside a wstring a C peer would stop at".to_string(),
3020 found: "a NUL before the terminator".to_string(),
3021 });
3022 }
3023 }
3024 String::from_utf8(bytes).map_err(|_| ProbeError::Mismatch {
3025 path: path.to_string(),
3026 expected: "well-formed UTF-8".to_string(),
3027 found: "an invalid sequence".to_string(),
3028 })
3029}
3030
3031/// `count` characters of `width` bytes each, as UTF-16 code units.
3032///
3033/// The four-byte form is RTI's XCDR1 one, where a character is a code
3034/// unit zero-extended; a value above `0xFFFF` there is not a form this
3035/// reader knows how to narrow, so it is refused rather than truncated.
3036fn read_wstring_units(
3037 r: &mut Reader<'_>,
3038 count: usize,
3039 width: usize,
3040 order: WstringUnitOrder,
3041 path: &str,
3042) -> Result<Vec<u16>, ProbeError> {
3043 let bytes = count.checked_mul(width).unwrap_or(usize::MAX);
3044 if bytes > r.remaining() {
3045 return Err(ProbeError::Mismatch {
3046 path: path.to_string(),
3047 expected: "a length the stream can hold".to_string(),
3048 found: format!("{bytes} byte(s) with {} left", r.remaining()),
3049 });
3050 }
3051 let mut units = Vec::with_capacity(count);
3052 for _ in 0..count {
3053 if width == 4 {
3054 // `ByteOrderMark` reads little-endian here and the mark is
3055 // re-examined once the whole run is in: a mark that says
3056 // `FFFE` means every unit has to be swapped back, which is
3057 // `strip_byte_order_mark`'s second job.
3058 let wide = match order {
3059 WstringUnitOrder::Stream => wire(r.read_u32(), path)?,
3060 WstringUnitOrder::BigEndianAlways => wire(r.read_u32_big(), path)?,
3061 WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped => {
3062 wire(r.read_u32_little(), path)?
3063 }
3064 };
3065 let unit = u16::try_from(wide).map_err(|_| ProbeError::Mismatch {
3066 path: path.to_string(),
3067 expected: "a four-byte wstring character holding one UTF-16 code unit".to_string(),
3068 found: format!("{wide:08x}"),
3069 })?;
3070 units.push(unit);
3071 } else {
3072 units.push(match order {
3073 WstringUnitOrder::Stream => wire(r.read_u16(), path)?,
3074 WstringUnitOrder::BigEndianAlways => wire(r.read_u16_big(), path)?,
3075 WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped => {
3076 wire(r.read_u16_little(), path)?
3077 }
3078 });
3079 }
3080 }
3081 Ok(units)
3082}
3083
3084/// The UTF-16 byte-order mark, `U+FEFF`, and its byte-swapped form.
3085const BYTE_ORDER_MARK: u16 = 0xFEFF;
3086/// `U+FFFE`, which is not a character: a mark read this way says the
3087/// units behind it are the other way round.
3088const SWAPPED_BYTE_ORDER_MARK: u16 = 0xFFFE;
3089
3090/// Take the byte-order mark off the front and apply what it said.
3091///
3092/// The units arrived little-endian, because that is what omniORB writes
3093/// on this host and what [`WstringUnitOrder::ByteOrderMark`] therefore
3094/// reads. Three cases, and the third is the one that matters:
3095///
3096/// - `FEFF` — the mark is the right way up, the units are as read.
3097/// - `FFFE` — the mark is byte-swapped, so every unit behind it is too,
3098/// and each one is swapped back. This is what a conforming big-endian
3099/// omniORB peer produces and what a reader that ignored the mark would
3100/// return as different characters, well formed and the right length.
3101/// - no mark — CORBA's rule for a GIOP 1.2 `wstring` without one is
3102/// UTF-16 big-endian, so every unit read little-endian is swapped.
3103///
3104/// The mark is never a character of the returned string in any of the
3105/// three, which is the half §7.4.1.1.2 cares about.
3106///
3107/// The stream's own byte order is not an argument and does not need to
3108/// be: reading the units little-endian and then obeying the mark is
3109/// correct in all four combinations of writer host and stream order,
3110/// because the mark is written in the same order as the content it
3111/// introduces. That is what a byte-order mark is for.
3112fn strip_byte_order_mark(units: &mut Vec<u16>) {
3113 match units.first().copied() {
3114 Some(BYTE_ORDER_MARK) => {
3115 units.remove(0);
3116 }
3117 Some(SWAPPED_BYTE_ORDER_MARK) => {
3118 units.remove(0);
3119 for unit in units.iter_mut() {
3120 *unit = unit.swap_bytes();
3121 }
3122 }
3123 _ => {
3124 for unit in units.iter_mut() {
3125 *unit = unit.swap_bytes();
3126 }
3127 }
3128 }
3129}
3130
3131/// A length the configured form cannot have produced.
3132///
3133/// The error names the form the length does fit, and the setting that
3134/// selects it. Reading it that way is not offered — the reader has
3135/// already refused — but a peer misconfiguration that reports "an odd
3136/// byte count" and nothing else costs an afternoon to place.
3137fn wstring_form_mismatch(policy: WstringLengthPolicy, declared: usize, path: &str) -> ProbeError {
3138 // Sweeps the whole product and not the measured five: a cell left
3139 // out of the sweep is a cell this diagnostic can never name, and
3140 // naming the cell the writer used is the whole point of it.
3141 let candidates: Vec<&str> = WstringLengthPolicy::ALL_CELLS
3142 .into_iter()
3143 .filter(|other| *other != policy && wstring_length_is_possible(*other, declared))
3144 .map(WstringLengthPolicy::as_str)
3145 .collect();
3146 let fits = if candidates.is_empty() {
3147 String::from("no other wstring length policy either")
3148 } else {
3149 format!("--wstring-length {}", candidates.join(" or "))
3150 };
3151 ProbeError::Mismatch {
3152 path: path.to_string(),
3153 expected: format!(
3154 "a wstring length the {} policy can produce",
3155 policy.as_str()
3156 ),
3157 found: format!("{declared}, which fits {fits}"),
3158 }
3159}
3160
3161/// Whether `declared` is a length `policy` could have written.
3162///
3163/// Only the shape of the number, which is all a refused read has to go
3164/// on: an odd count is not a byte count, and a zero count cannot
3165/// include a terminator.
3166const fn wstring_length_is_possible(policy: WstringLengthPolicy, declared: usize) -> bool {
3167 // The two rules the coordinates give, and no list of named forms:
3168 // an octet count has to divide by the unit width, and a count that
3169 // includes a terminator cannot be zero. A cell nobody has measured
3170 // is checked by the same two.
3171 //
3172 // A byte count and `HostUnswapped`'s byte count are the same number
3173 // — that point of C4 differs in the *content*'s byte order, which
3174 // no length can show — so a diagnostic that names one names both.
3175 // That is a property of C4 now and not a special case here.
3176 if matches!(policy.counts, WstringLengthCounts::Octets)
3177 && declared % policy.unit.octets() as usize != 0
3178 {
3179 return false;
3180 }
3181 if matches!(policy.terminator, WstringTerminator::Present) && declared < 1 {
3182 return false;
3183 }
3184 true
3185}
3186
3187/// `fixed<P, S>`: packed BCD, [CDR] §9.3.2.8.
3188///
3189/// Every rule about the field — the declaration's bound, how its end is
3190/// found under each digit count, which nibble is a sign, where the point
3191/// sits, and the one spelling a sample comes back as — is in
3192/// `zerodds_wire::Reader::read_fixed`, which is where the twenty-first
3193/// language column reaches it too. What stays here is the path: `Reader`
3194/// says what it expected and at which byte, and a refusal over a
3195/// fifty-member document that cannot say which member it was is one nobody
3196/// can act on.
3197fn read_fixed<V: Sink>(
3198 r: &mut Reader<'_>,
3199 digits: u8,
3200 scale: u8,
3201 policy: FixedDigitCount,
3202 path: &str,
3203) -> Result<V, ProbeError> {
3204 let text = r
3205 .read_fixed(digits, scale, crate::encode::fixed_digits(policy))
3206 .map_err(|error| ProbeError::Mismatch {
3207 path: path.to_string(),
3208 expected: format!("a fixed<{digits}, {scale}> the stream can carry"),
3209 found: format!("{error}"),
3210 })?;
3211 Ok(V::fixed(text))
3212}
3213
3214fn read_primitive<V: Sink>(
3215 r: &mut Reader<'_>,
3216 kind: PrimitiveKind,
3217 position: WcharPosition,
3218 path: &str,
3219) -> Result<V, ProbeError> {
3220 match kind {
3221 PrimitiveKind::Boolean => Ok(V::boolean(wire(r.read_bool(), path)?)),
3222 PrimitiveKind::Octet | PrimitiveKind::UInt8 => {
3223 V::integer(kind, i128::from(wire(r.read_u8(), path)?), path)
3224 }
3225 PrimitiveKind::Int8 => V::integer(kind, i128::from(wire(r.read_i8(), path)?), path),
3226 PrimitiveKind::Char => Ok(V::character(kind, wire(r.read_char(), path)?)),
3227 PrimitiveKind::WChar => Ok(V::character(kind, wire(r.read_wchar(position), path)?)),
3228 PrimitiveKind::Int16 => V::integer(kind, i128::from(wire(r.read_i16(), path)?), path),
3229 PrimitiveKind::UInt16 => V::integer(kind, i128::from(wire(r.read_u16(), path)?), path),
3230 PrimitiveKind::Int32 => V::integer(kind, i128::from(wire(r.read_i32(), path)?), path),
3231 PrimitiveKind::UInt32 => V::integer(kind, i128::from(wire(r.read_u32(), path)?), path),
3232 // Which spelling a sixty-four-bit width takes is the
3233 // representation's, and it is told which width it has: a JSON
3234 // number is a double and loses the low bits past 2^53.
3235 PrimitiveKind::Int64 => V::integer(kind, i128::from(wire(r.read_i64(), path)?), path),
3236 PrimitiveKind::UInt64 => V::integer(kind, i128::from(wire(r.read_u64(), path)?), path),
3237 PrimitiveKind::Float32 => V::float(kind, f64::from(wire(r.read_f32(), path)?), path),
3238 PrimitiveKind::Float64 => V::float(kind, wire(r.read_f64(), path)?, path),
3239 // A string, like the 64-bit widths above and for the same
3240 // reason: a JSON number is a double, and 60 of a long double's
3241 // 113 significand bits do not fit one.
3242 //
3243 // The *exact* expansion and not the shortest one. Every binary
3244 // value has finitely many decimal digits and every column can
3245 // print them with nothing but long multiplication; the shortest
3246 // form needs a parser and a search, and four columns that each
3247 // implement that search will disagree about which of two equally
3248 // short spellings to emit long before they disagree about the
3249 // value. `crates/float128` still has `to_shortest_decimal` for
3250 // callers that want it — this is the canonical form, not the
3251 // readable one.
3252 PrimitiveKind::Float128 => Ok(V::float128(wire(r.read_f128(), path)?)),
3253 }
3254}
3255
3256/// Move the cursor to `target`, which is never backwards.
3257///
3258/// A framing word that pointed behind the cursor would mean the reader
3259/// had already read past the end of what it framed, and that is caught
3260/// where the word is read rather than papered over by seeking back.
3261fn skip_to(r: &mut Reader<'_>, target: usize) {
3262 while r.position() < target && r.remaining() > 0 {
3263 // `read_u8` is the one read that never aligns, so it advances by
3264 // exactly one whatever the offset is.
3265 if r.read_u8().is_err() {
3266 return;
3267 }
3268 }
3269}
3270
3271fn bound_value(ast: &Ast, bound: &Bound) -> Option<i128> {
3272 match bound.expr() {
3273 Some(expr) => ast.eval_integral(expr).ok(),
3274 None => None,
3275 }
3276}
3277
3278/// The integer a **stream-side** value carries.
3279///
3280/// The same [`Source::as_integer`] a value file goes through, and it can
3281/// be, because the value here is one this crate has just built: a
3282/// discriminator is an integer, a `char`, a `boolean` or an enumeration
3283/// (§7.4.1.4.4.4.2), the first three are handled above and the fourth
3284/// is read out of a field no wider than sixty-four bits — which
3285/// [`Sink::integer`] has already spelled in whatever form the
3286/// representation reserves for that width. So no spelling reaches here
3287/// that the representation would refuse.
3288///
3289/// It used to be a second reading with the range rule left out, on the
3290/// argument that the spelling was this project's own. That argument is
3291/// what makes the shared one safe; it is not a reason to have two.
3292fn integer_of<V: Source>(value: &V, path: &str) -> Result<i128, ProbeError> {
3293 value.as_integer(path)
3294}
3295
3296/// A wire error, with the path the value walk is at put back on it.
3297///
3298/// `WireError` names the offset, which is what a reader knows; the path
3299/// is what a caller reading a fifty-member sample needs.
3300fn wire<T>(result: Result<T, WireError>, path: &str) -> Result<T, ProbeError> {
3301 result.map_err(|error| ProbeError::Mismatch {
3302 path: path.to_string(),
3303 expected: "a readable value".to_string(),
3304 found: format!("{error}"),
3305 })
3306}
3307
3308#[cfg(test)]
3309mod padding_tests {
3310 use super::padding_present;
3311
3312 /// XTypes 1.3 §7.6.3.1.2: the count is honoured where the octets make
3313 /// the payload a multiple of four, and ignored where they cannot be
3314 /// there — OpenDDS 3.34's 70-octet payload counting two. A count the
3315 /// body cannot hold comes back whole, for the caller to refuse.
3316 #[test]
3317 fn padding_is_counted_only_where_it_can_be_present() {
3318 assert_eq!(padding_present(8, 0x02), 2);
3319 assert_eq!(padding_present(68, 0x02), 2);
3320 assert_eq!(padding_present(70, 0x02), 0);
3321 assert_eq!(padding_present(9, 0x03), 0);
3322 assert_eq!(padding_present(8, 0x00), 0);
3323 assert_eq!(padding_present(4, 0x01), 1);
3324 assert_eq!(padding_present(5, 0x03), 3);
3325 }
3326}