Skip to main content

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}