zerodds_wire_codec/encode.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! One value, one type, the bytes between them.
4//!
5//! The walk follows the layout plan, not a second reading of the
6//! model: whether a structure carries a delimiter, what header each
7//! member takes and which length code it uses are all the plan's
8//! decisions. A probe that decided them again would agree with the
9//! plan only until one of the two changed.
10
11use crate::contract::{self, Consumed};
12use crate::source::{Fields, Source};
13use crate::value::{
14 Emit, MapLengthPolicy, ProbeRequest, encapsulation_header_in, encapsulation_padding,
15 wire_wchar_framing_of, wire_wchar_width_of, wire_wstring_unit_order_of,
16};
17use std::string::String;
18use std::vec::Vec;
19use zerodds_ast::{Ast, Bound, CharWidth, PrimitiveKind, TypeKind, TypeNode};
20use zerodds_ast_core::{Id, Ref, ScopedName};
21use zerodds_ast_layout::{
22 DepCollector, ElementPadding, Endianness, EnumeratorValue, Extent, FixedDigitCount,
23 KeyHashForm, LayoutKey, LengthCode, MapKeyOrdering, MemberHeader, Purpose, TypeLayout,
24 WirePosition, WstringCodeUnits, WstringLengthCounts, WstringLengthPolicy, WstringTerminator,
25 WstringUnitOrder, WstringUnitWidth, array_is_delimited, collection_is_delimited,
26 enumeration_bit_bound, enumerator_wire_value, key_members_with, map_key_ordering, member_key,
27 plan, union_negative_matches_nothing,
28};
29use zerodds_wire::{ParameterRole, WcharPosition, Writer};
30
31/// Why a probe could not produce bytes.
32///
33/// Every variant names where in the value the trouble is, because a
34/// probe that said only "type mismatch" over a fifty-member sample is
35/// a probe nobody uses twice.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ProbeError {
38 /// The requested type is not in the model.
39 NoSuchType {
40 /// The scoped name asked for.
41 name: String,
42 },
43 /// The layout could not be planned.
44 NoLayout {
45 /// What the planner said.
46 detail: String,
47 },
48 /// The value at `path` is not what the type calls for.
49 Mismatch {
50 /// Where, as a dotted path from the sample's root.
51 path: String,
52 /// What the type wanted.
53 expected: String,
54 /// What the value held.
55 found: String,
56 },
57 /// A member the type declares has no value and is not optional.
58 Missing {
59 /// Where.
60 path: String,
61 },
62 /// The stream is a well-formed sample of a compatible type, and no
63 /// object of *this* type can be constructed from it.
64 ///
65 /// The `try_construct` outcome of DDS-XTypes 1.3 §7.2.2.7 —
66 /// a `string<32>` holding twelve characters arriving where the
67 /// receiver declares `string<5>`, a `sequence<long,32>` of eight
68 /// arriving at a bound of four, an enumerator this type does not
69 /// declare — where the governing behaviour is `DISCARD`.
70 ///
71 /// # Why this is not one of the other variants
72 ///
73 /// §7.2.4.1: "the default behavior is to discard the O2 object to
74 /// avoid misinterpretation", and §7.2.3: the sample "can be safely
75 /// ignored (filtered out) by the receiver". A discarded sample is an
76 /// **absence**, not an error: a DDS subscriber's `take()` returns
77 /// nothing and no listener fires. Measured — in Cyclone the
78 /// normalize failure makes `ddsi_serdata_from_ser` return `NULL`,
79 /// `remote_make_sample` passes the `NULL` on and delivery ends at
80 /// `remote_on_delivery_failure_fastpath`, so the sample never
81 /// becomes one.
82 ///
83 /// [`Self::Mismatch`] and the framing errors underneath it say the
84 /// *stream* is wrong and the peer needs fixing. This says one sample
85 /// does not fit a type that is otherwise perfectly compatible, and
86 /// the next sample may well fit. Keeping them apart is the whole
87 /// point: a subscriber that logged every narrowed sample as a
88 /// protocol error would drown, and one that treated a malformed
89 /// stream as a narrowing would never notice a broken peer.
90 Discarded {
91 /// Where, as a dotted path from the sample's root.
92 path: String,
93 /// What this type can hold.
94 expected: String,
95 /// What arrived.
96 found: String,
97 },
98 /// The value holds a key the type does not declare.
99 ///
100 /// An error and not a warning: a misspelled member name would
101 /// otherwise silently test the default value.
102 Unknown {
103 /// Where.
104 path: String,
105 /// The keys the type does declare, so the typo is visible.
106 declared: Vec<String>,
107 },
108 /// A construct this target has no form for.
109 ///
110 /// `kind` carries the whole reason, in the target's terms — "a
111 /// bitmask, which is XTypes 1.3 §7.3.1.2.1.4 and not CORBA IDL",
112 /// "a map, which the IDL-to-C mapping §7.5.1 reuses does not
113 /// have". The rendering used to append "is not serialised by this
114 /// probe yet" to every one of them, which turned a limit of CORBA
115 /// or of a vendor's front end into a backlog item of ours. It was
116 /// read that way: a review of why nine toolchains produced nothing
117 /// for so many corpus cases classified all 127 of TAO's as ours on
118 /// the strength of that suffix, when every one of them names a
119 /// construct CORBA IDL does not have.
120 ///
121 /// Where the limit really is this probe's, [`Self::Unfilled`] says
122 /// so in its own words.
123 Unsupported {
124 /// Where.
125 path: String,
126 /// Which construct, and why this target has no form for it.
127 kind: &'static str,
128 },
129 /// The target took the construct and its own generated code does
130 /// not build.
131 ///
132 /// A measurement of the toolchain and not of its specification,
133 /// which is why it is not [`Self::Unsupported`]: `rosidl`'s IDL
134 /// takes `wchar[3]` without a word, and what
135 /// `rosidl_typesupport_fastrtps_c` 2.2.2 emits calls an undeclared
136 /// `callbacks`. Recorded as a missing form, it said ROS has no
137 /// collection of wchar — the opposite of what was measured.
138 GeneratorDefect {
139 /// Where.
140 path: String,
141 /// What was measured, including the emitted line.
142 kind: &'static str,
143 },
144 /// The target carries the construct and a documented mapping rule
145 /// makes the octets differ.
146 ///
147 /// Also a measurement, and also not a missing form. `omniidl` and
148 /// `tao_idl` both take `long double`; CORBA's C++ mapping makes it
149 /// the platform's x87 extended precision and not §9.3.1.3's
150 /// binary128.
151 MappingDivergence {
152 /// Where.
153 path: String,
154 /// The rule, with its clause.
155 kind: &'static str,
156 },
157 /// A construct this probe has not implemented.
158 ///
159 /// Separate from [`Self::Unsupported`], which is a limit of the
160 /// target. Twenty-one sites reach a catch-all arm and have only
161 /// the construct's own name to give — `union`, `map` — and a bare
162 /// noun as a whole message says neither what is wrong nor whose
163 /// gap it is. This variant supplies the sentence around it.
164 Unfilled {
165 /// Where.
166 path: String,
167 /// The construct's name, as the model spells it.
168 kind: &'static str,
169 },
170 /// The value nests deeper than [`crate::MAX_VALUE_DEPTH`]
171 /// aggregates.
172 ///
173 /// A recursive type has finite values because each one terminates
174 /// itself — a sequence of length zero, an absent `@optional`
175 /// member. A value whose object graph has a cycle, `a.next = a`,
176 /// terminates nowhere, and XTypes 1.3 §7.3.1.2.1.4 says of it "if
177 /// the graph of objects has cycles, it is not serializable".
178 ///
179 /// The walk cannot see the cycle — it is handed one member, or one
180 /// header, at a time — so it counts, and says this. Not a silent
181 /// truncation and not a stack overflow: the caller is told which
182 /// value, how deep it went and what the limit is.
183 ValueTooDeep {
184 /// Where, as a dotted path from the sample's root.
185 path: String,
186 /// The limit that was passed, [`crate::MAX_VALUE_DEPTH`].
187 limit: usize,
188 },
189 /// A KeyHash was asked for on a type that declares no `@key`.
190 ///
191 /// Not "unsupported" and not a zero hash: a keyless topic has one
192 /// instance, so there is no handle to compute, and Cyclone, RTI and
193 /// Fast DDS all decide that before any hash is. See `key_hash`.
194 NoKey {
195 /// The type asked about.
196 type_name: String,
197 },
198}
199
200impl core::fmt::Display for ProbeError {
201 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
202 match self {
203 Self::NoSuchType { name } => write!(f, "`{name}` is not in the model"),
204 Self::NoLayout { detail } => write!(f, "the layout could not be planned: {detail}"),
205 Self::Mismatch {
206 path,
207 expected,
208 found,
209 } => write!(f, "{path}: expected {expected}, found {found}"),
210 Self::Missing { path } => write!(f, "{path}: no value, and the member is not optional"),
211 Self::Discarded {
212 path,
213 expected,
214 found,
215 } => write!(
216 f,
217 "the sample is discarded: at {path} this type holds {expected} and the \
218 sample carries {found}, and `@try_construct` there is DISCARD"
219 ),
220 Self::Unknown { path, declared } => {
221 write!(f, "{path}: not a member; the type declares {declared:?}")
222 }
223 Self::Unsupported { path, kind } => write!(f, "{path}: {kind}"),
224 Self::GeneratorDefect { path, kind } => write!(f, "{path}: {kind}"),
225 Self::MappingDivergence { path, kind } => write!(f, "{path}: {kind}"),
226 Self::Unfilled { path, kind } => {
227 // "an enum", not "a enum": the article is the only word
228 // this variant adds and getting it wrong reads as a
229 // machine talking.
230 let article = if kind.starts_with(['a', 'e', 'i', 'o', 'u']) {
231 "an"
232 } else {
233 "a"
234 };
235 write!(
236 f,
237 "{path}: {article} {kind}, which this probe does not carry yet"
238 )
239 }
240 Self::ValueTooDeep { path, limit } => write!(
241 f,
242 "{path}: the value nests more than {limit} aggregates deep; a value whose object \
243 graph has a cycle is not serializable — XTypes 1.3 §7.3.1.2.1.4"
244 ),
245 Self::NoKey { type_name } => write!(
246 f,
247 "`{type_name}` declares no `@key`, so it has one instance and no KeyHash"
248 ),
249 }
250 }
251}
252
253impl core::error::Error for ProbeError {}
254
255/// What a walk carries down: the key the plan is computed under, and
256/// the one framing question no plan answers.
257///
258/// The layout key is the planner's own input type, so a walk that
259/// consults the plan and a walk that writes the bytes cannot drift
260/// apart. The map policy is separate because the contradiction it
261/// resolves is in the serialisation clause, not in the layout.
262#[derive(Debug, Clone, Copy)]
263struct Frame {
264 key: LayoutKey,
265 map_length: MapLengthPolicy,
266 /// Whether the type about to be written is reached as a member or
267 /// as a collection element.
268 ///
269 /// It rides in the frame rather than as an argument of its own for
270 /// the reason the recorder rides with the writer: there is one thing
271 /// to thread and a new arm of [`write_type`] cannot forget half of
272 /// it. Only `wchar` reads it, and only under TAO's framing — see
273 /// `zerodds_ast_layout::WcharPosition`.
274 wchar: WcharPosition,
275 /// How many aggregates of the *value* the walk is inside.
276 ///
277 /// The type graph's depth is the planner's and is bounded by the
278 /// model; this is the value's, and a recursive type puts no bound
279 /// on it at all. See [`crate::MAX_VALUE_DEPTH`].
280 depth: usize,
281}
282
283impl Frame {
284 /// The frame an element of a collection is written in.
285 fn in_collection(self) -> Self {
286 Self {
287 wchar: WcharPosition::Element,
288 ..self
289 }
290 }
291
292 /// The frame an aggregate's own members are written in.
293 ///
294 /// An aggregate resets the position: a `wchar` member of a struct
295 /// that is itself an array element is reached through the struct's
296 /// marshaller and takes the member's framing.
297 ///
298 /// It is also the one place the value's depth grows, which is why
299 /// the counter rides in the frame rather than beside it: every
300 /// descent into an aggregate goes through here, so no arm of the
301 /// walk can descend without counting.
302 fn in_aggregate(self) -> Self {
303 Self {
304 wchar: WcharPosition::Member,
305 depth: self.depth.saturating_add(1),
306 ..self
307 }
308 }
309
310 /// The refusal for a value that nests past [`crate::MAX_VALUE_DEPTH`],
311 /// or `Ok(())` while it does not.
312 ///
313 /// # Errors
314 /// [`ProbeError::ValueTooDeep`].
315 fn within_depth(self, path: &str) -> Result<(), ProbeError> {
316 if self.depth >= crate::MAX_VALUE_DEPTH {
317 return Err(ProbeError::ValueTooDeep {
318 path: String::from(path),
319 limit: crate::MAX_VALUE_DEPTH,
320 });
321 }
322 Ok(())
323 }
324}
325
326/// The bytes, and a note of every scalar the walk took out of the value.
327///
328/// The recorder rides with the writer rather than beside it so that a
329/// new arm of `write_type` cannot forget to thread it: there is one
330/// mutable thing to pass and it carries both.
331struct Sink {
332 w: Writer,
333 seen: Consumed,
334}
335
336/// Serialise `values` as `request.type_name`.
337///
338/// # Errors
339/// [`ProbeError`], always naming the path in the value.
340pub fn encode<V: Source>(
341 ast: &Ast,
342 request: &ProbeRequest,
343 values: &V,
344) -> Result<Vec<u8>, ProbeError> {
345 encode_recording(ast, request, values, &mut Consumed::new())
346}
347
348/// As [`encode`], recording which scalars of the value were read.
349///
350/// The three probes read one value file, and a file that one of them
351/// reads whole and another reads in part is the defect this exists to
352/// make visible. `tests/probe_agreement.rs` compares the recordings.
353///
354/// # Errors
355/// As [`encode`].
356pub fn encode_recording<V: Source>(
357 ast: &Ast,
358 request: &ProbeRequest,
359 values: &V,
360 seen: &mut Consumed,
361) -> Result<Vec<u8>, ProbeError> {
362 encode_noting_padding(ast, request, values, seen, false, 0).map(|(bytes, _)| bytes)
363}
364
365/// As [`encode`], for a value that continues a sample whose first `start`
366/// octets someone else wrote: alignment counts from the start of the
367/// sample, and only the value's own octets are returned.
368///
369/// DDS-RPC 1.0 §7.5.1.1.6 puts the `Call` union behind a header of
370/// variable length, and under XCDR1 an 8-octet member of it aligns to the
371/// sample. The request must be `Emit::Payload`.
372///
373/// # Errors
374/// As [`encode`]; `ProbeError::Unsupported` for another `emit`.
375pub fn encode_continuing<V: Source>(
376 ast: &Ast,
377 request: &ProbeRequest,
378 values: &V,
379 start: usize,
380) -> Result<Vec<u8>, ProbeError> {
381 if request.emit != Emit::Payload {
382 return Err(ProbeError::Unsupported {
383 path: request.type_name.clone(),
384 kind: "a continuation behind an encapsulation header or as a KeyHash",
385 });
386 }
387 encode_noting_padding(ast, request, values, &mut Consumed::new(), false, start)
388 .map(|(bytes, _)| bytes)
389}
390
391/// As [`encode`], and the offsets of the octets that carry no value.
392///
393/// The second half of the answer is computed by the **layout**, not by
394/// comparing the result with anything: [`Writer::align_to`] is the one
395/// place the walk emits a byte that XTypes 1.3 §7.4.1's alignment rules
396/// put there instead of a value, and the writer notes each span it
397/// emits. The result is a mask over the returned byte string, one entry
398/// per octet, true where the plan says padding.
399///
400/// The interop comparison uses it to check its own octet rule: a peer's
401/// differing octet that lands outside this mask is a difference in a
402/// value, whatever it looks like. See
403/// `tools/interop-results/src/rust_column.rs`.
404///
405/// # Errors
406/// As [`encode`]. `Emit::KeyHash` has no padding mask — the holder is a
407/// separate stream — and yields an all-false one.
408pub fn encode_with_padding<V: Source>(
409 ast: &Ast,
410 request: &ProbeRequest,
411 values: &V,
412) -> Result<(Vec<u8>, Vec<bool>), ProbeError> {
413 let (bytes, spans) =
414 encode_noting_padding(ast, request, values, &mut Consumed::new(), true, 0)?;
415 let mut mask = vec![false; bytes.len()];
416 for (at, length) in spans {
417 for octet in mask.iter_mut().skip(at).take(length) {
418 *octet = true;
419 }
420 }
421 Ok((bytes, mask))
422}
423
424/// A stream and the spans of it that carry no value, as
425/// `(offset, length)` in the finished stream's coordinates.
426type StreamWithPadding = (Vec<u8>, Vec<(usize, usize)>);
427
428fn encode_noting_padding<V: Source>(
429 ast: &Ast,
430 request: &ProbeRequest,
431 values: &V,
432 seen: &mut Consumed,
433 note_padding: bool,
434 start: usize,
435) -> Result<StreamWithPadding, ProbeError> {
436 let type_ = find_type(ast, &request.type_name)?;
437 let root = contract::root_path(ast, type_);
438
439 if request.emit == Emit::KeyHash {
440 return key_hash(ast, type_, values, request, seen).map(|bytes| (bytes, Vec::new()));
441 }
442
443 // A dialect that frames the whole sample one way or the other
444 // decides here, once, for the sample's type; every nested type is
445 // framed by that choice.
446 let frame = Frame {
447 key: request
448 .layout_key(Purpose::Data)
449 .for_sample(ast, type_)
450 .map_err(|e| ProbeError::NoLayout {
451 detail: e.to_string(),
452 })?,
453 map_length: request.map_length,
454 wchar: WcharPosition::Member,
455 depth: 0,
456 };
457 let writer = Writer::continuing(
458 wire_endianness(request.endianness),
459 usize::from(request.encoding.max_alignment()),
460 vec![0; start],
461 )
462 .with_pl_cdr1_framing(request.pl_cdr1_framing())
463 .with_wchar_width(request.wchar_width())
464 .with_wchar_framing(request.wire_wchar_framing())
465 .with_wstring_unit_order(request.wire_wstring_unit_order())
466 // The half of `ShortWhereItFits` a plan cannot answer: a
467 // sequence whose count is in the value. See
468 // `LengthCodePolicy` and `Writer::with_short_length_codes`.
469 .with_short_length_codes(request.length_codes.short_code_where_it_fits());
470 let mut sink = Sink {
471 w: if note_padding {
472 writer.recording_padding()
473 } else {
474 writer
475 },
476 seen: core::mem::take(seen),
477 };
478 let result = write_type(&mut sink, ast, type_, values, frame, &root);
479 *seen = core::mem::take(&mut sink.seen);
480 result?;
481
482 let mut spans = sink.w.padding_spans().to_vec();
483 let mut payload = sink.w.finish();
484 // The octets a continuation was counted from are not the value's.
485 payload.drain(..start);
486 if request.emit == Emit::PayloadWithEncapsulation {
487 // §7.4.1.1 pads the serialized data to the next four-byte
488 // boundary with zeros, and §7.6.3.1.2 puts the count of those
489 // bytes in the encapsulation options so a receiver can find
490 // where the data ended. Both halves or neither: padding without
491 // the count hands a conforming receiver up to three bytes of
492 // zero as data, and the count without the padding tells it to
493 // cut bytes that are data.
494 //
495 // Cyclone, RTI and Fast DDS all write both — measured on the
496 // loopback wire, `encapsulation_header`'s documentation has the
497 // capture. `Emit::Payload` stays unpadded: that is the
498 // serialized data itself, and it is what `vendor_bytes.md`
499 // records for all three.
500 let padding = encapsulation_padding(payload.len());
501 let mut out = Vec::with_capacity(payload.len() + 4 + usize::from(padding));
502 out.extend_from_slice(&encapsulation_header_in(
503 request.encapsulation_ids,
504 request.encoding,
505 request.endianness,
506 request.endianness == Endianness::Little,
507 padding,
508 ));
509 out.extend_from_slice(&payload);
510 // The four-octet encapsulation header moves every span, and the
511 // trailing fill is padding by the same clause.
512 for span in &mut spans {
513 span.0 += 4;
514 }
515 if padding != 0 {
516 spans.push((out.len(), usize::from(padding)));
517 }
518 out.resize(out.len() + usize::from(padding), 0);
519 return Ok((out, spans));
520 }
521 Ok((payload, spans))
522}
523
524/// The sixteen-byte KeyHash. DDSI-RTPS 2.5 §9.6.4.8, XTypes 1.3 §7.6.8.
525///
526/// Big-endian and member-id order whatever the data request asked for:
527/// two peers that disagree about either compute different hashes for
528/// the same instance.
529///
530/// # A type with no `@key` member has no KeyHash
531///
532/// This used to return sixteen bytes for one, and not the same sixteen:
533/// zeroes when the empty holder planned `Extent::Sized` and
534/// `d41d8cd98f00b204e9800998ecf8427e` — the MD5 of nothing — when it
535/// planned `Extent::Unbounded`, which is to say whenever the type was
536/// `@mutable`. Two answers to a question with no answer.
537///
538/// Read literally, Step 1 of §9.6.4.8 says a keyless holder keeps every
539/// member — "If there are any key members, then remove the non-key
540/// members from FooKeyHolder. Otherwise, do not remove any members." No
541/// implementation does that, because the clause it serves does not
542/// arise: a keyless topic has one instance, so there is no handle to
543/// distinguish. Measured against the three: Cyclone, RTI and Fast DDS
544/// all decide on `nkeys == 0` before any hash is computed and produce
545/// none. Cyclone's counterfactual — what its own code would give if the
546/// decision were removed — is MD5 of the empty string for `@final` and
547/// `@mutable` alike, with no split of any kind. The keyed path agrees
548/// with all of them: §9.6.4.8 Example 1 gives
549/// `12345678 00000000 00000000 00000000`, and so do Cyclone, Fast DDS
550/// and this probe.
551///
552/// So the answer is neither of the two this returned. Asking for the
553/// KeyHash of a type that has no instances is a mistake made upstream,
554/// and sixteen bytes of anything hides it. `ProbeError::NoKey` is what
555/// comes back, and `crates/lang-rules/src/encode.rs::key_hash_fn` —
556/// which has emitted no `key_hash` for a keyless type all along — is the
557/// half of the project that was right.
558fn key_hash<V: Source>(
559 ast: &Ast,
560 type_: Id<TypeNode>,
561 values: &V,
562 request: &ProbeRequest,
563 seen: &mut Consumed,
564) -> Result<Vec<u8>, ProbeError> {
565 key_material(ast, type_, values, request, seen, true)
566}
567
568/// Encode the writer's own `serializedKey` (DDSI-RTPS 2.5 §9.4.2.11) — the
569/// key-only payload a dispose/unregister DATA carries behind its K flag —
570/// in the representation and byte order `request` names.
571///
572/// The exact inverse of [`crate::decode::serialized_key`]: the same
573/// `Purpose::Key` plus `request.key_holder`/`key_holder_order`/
574/// `key_holder_nesting` (`AsDeclared` + `Declaration`, `Recursive` nesting
575/// already the default) select the type's own extensibility framing (a
576/// DHEADER for `@appendable`/`@mutable` under XCDR2, a per-member EMHEADER
577/// for `@mutable`, nothing for `@final` or under XCDR1) and declaration
578/// order, in `request`'s own encoding and endianness — not the canonical
579/// big-endian PLAIN_CDR2 form [`key_holder`] always writes.
580///
581/// `values` needs only the `@key` members populated: `write_aggregate`
582/// walks [`plan()`]'s already-filtered member list (the same `Purpose::Key`
583/// "holder" plan [`key_holder`]'s canonical form uses, just under a
584/// different `key_holder`/`key_holder_order`), so a non-key member is
585/// never looked up.
586///
587/// `Emit::PayloadWithEncapsulation` wraps the result behind the four-byte
588/// encapsulation header and zero-pads it to a four-byte boundary, with the
589/// pad count in the header's low two option bits (XTypes 1.3 §7.6.3.1.2) —
590/// the same convention [`encode`] uses for a full sample.
591///
592/// # Errors
593/// [`ProbeError`]: the type is not in the model, [`plan()`] cannot describe
594/// it under this key, or a `@key` member's value is missing or refused.
595pub fn serialized_key<V: Source>(
596 ast: &Ast,
597 request: &ProbeRequest,
598 values: &V,
599) -> Result<Vec<u8>, ProbeError> {
600 let type_ = find_type(ast, &request.type_name)?;
601 let root = contract::root_path(ast, type_);
602 let frame = Frame {
603 key: request.layout_key(Purpose::Key),
604 map_length: request.map_length,
605 wchar: WcharPosition::Member,
606 depth: 0,
607 };
608 let writer = Writer::with_max_align(
609 wire_endianness(request.endianness),
610 usize::from(request.encoding.max_alignment()),
611 )
612 .with_pl_cdr1_framing(request.pl_cdr1_framing())
613 .with_wchar_width(request.wchar_width())
614 .with_wchar_framing(request.wire_wchar_framing())
615 .with_wstring_unit_order(request.wire_wstring_unit_order())
616 .with_short_length_codes(request.length_codes.short_code_where_it_fits());
617 let mut sink = Sink {
618 w: writer,
619 seen: Consumed::new(),
620 };
621 write_type(&mut sink, ast, type_, values, frame, &root)?;
622 let payload = sink.w.finish();
623 if request.emit != Emit::PayloadWithEncapsulation {
624 return Ok(payload);
625 }
626 // §7.4.1.1 pads the serialized data to the next four-byte boundary
627 // with zeros, and §7.6.3.1.2 puts the count of those bytes in the
628 // encapsulation options — the same rule `encode` applies to a full
629 // sample, measured against Cyclone, RTI and Fast DDS there and against
630 // Cyclone's own `serializedKey` octets here (`decode::serialized_key`'s
631 // documentation has the measured vectors).
632 let padding = encapsulation_padding(payload.len());
633 let mut out = Vec::with_capacity(payload.len() + 4 + usize::from(padding));
634 out.extend_from_slice(&encapsulation_header_in(
635 request.encapsulation_ids,
636 request.encoding,
637 request.endianness,
638 request.endianness == Endianness::Little,
639 padding,
640 ));
641 out.extend_from_slice(&payload);
642 out.resize(out.len() + usize::from(padding), 0);
643 Ok(out)
644}
645
646/// Actual serialized holder, before zero padding or hashing.
647///
648/// # Errors
649/// The same layout and value refusals as the KeyHash encoder.
650pub fn key_holder<V: Source>(
651 ast: &Ast,
652 request: &ProbeRequest,
653 values: &V,
654) -> Result<Vec<u8>, ProbeError> {
655 let type_ = find_type(ast, &request.type_name)?;
656 key_material(ast, type_, values, request, &mut Consumed::new(), false)
657}
658
659fn key_material<V: Source>(
660 ast: &Ast,
661 type_: Id<TypeNode>,
662 values: &V,
663 request: &ProbeRequest,
664 seen: &mut Consumed,
665 hash: bool,
666) -> Result<Vec<u8>, ProbeError> {
667 let holder_key = request.key_holder_key();
668 let form = zerodds_ast_layout::keyhash::key_hash_form_for(ast, type_, holder_key).map_err(
669 |error| ProbeError::NoLayout {
670 detail: format!("{error}"),
671 },
672 )?;
673 let frame = Frame {
674 key: holder_key,
675 map_length: request.map_length,
676 wchar: WcharPosition::Member,
677 depth: 0,
678 };
679
680 // The holder is PLAIN_CDR2 big-endian whatever the data encoding
681 // is, so its alignment cap is the one PLAIN_CDR2 has (§7.6.8) — unless
682 // the dialect aligns the holder to its data encoding or packs it
683 // (`KeyHolderAlignment`: Cyclone and RTI under XCDR1, OpenSplice).
684 let mut sink = Sink {
685 w: Writer::with_max_align(
686 zerodds_wire::Endianness::Big,
687 usize::from(
688 holder_key
689 .key_holder_alignment
690 .max_alignment(holder_key.encoding),
691 ),
692 )
693 // From the holder's own key, so that the width the writer uses
694 // is the width `primitive_layout` sized the member at. Which
695 // width a key holder should use for a `wchar` is unmeasured —
696 // no vendor row has one in a key — and a writer that disagreed
697 // with its own plan would be wrong whatever the answer is.
698 .with_wchar_width(wire_wchar_width_of(holder_key.wchar_width))
699 // As the width: from the holder's own key, so that the framing
700 // the writer uses is the framing `primitive_layout` sized the
701 // member at.
702 .with_wchar_framing(wire_wchar_framing_of(holder_key.wchar_framing))
703 .with_wstring_unit_order(wire_wstring_unit_order_of(holder_key.wstring_unit_order)),
704 seen: core::mem::take(seen),
705 };
706 let root = contract::root_path(ast, type_);
707 // A union keyed by its discriminator has no `@key` member at all —
708 // Table 21 puts the annotation on the discriminator, which is not a
709 // `MemberNode` — so `key_members_with` answers the empty list and
710 // would report the type keyless. §7.6.8's holder for it is the
711 // discriminator, and the planner above sized it as exactly that.
712 if let Some(discriminator) = zerodds_ast_layout::union_key_discriminator(ast, type_) {
713 let path = contract::join(&root, "_d");
714 let value = values
715 .field("_d")
716 .ok_or_else(|| ProbeError::Missing { path: path.clone() })?;
717 write_type(&mut sink, ast, discriminator, value, frame, &path)?;
718 *seen = core::mem::take(&mut sink.seen);
719 if !hash {
720 return Ok(sink.w.finish());
721 }
722 return Ok(match form {
723 KeyHashForm::ZeroPadded => sink.w.zero_padded().to_vec(),
724 KeyHashForm::Md5 => sink.w.md5().to_vec(),
725 });
726 }
727 let keys = key_members_with(
728 ast,
729 type_,
730 holder_key.key_holder_nesting,
731 holder_key.key_holder_order,
732 )
733 .map_err(|error| ProbeError::NoLayout {
734 detail: format!("{error}"),
735 })?;
736 if keys.is_empty() {
737 return Err(ProbeError::NoKey {
738 type_name: request.type_name.clone(),
739 });
740 }
741 let mut result = Ok(());
742 for node in &keys {
743 let Some((name, member_type, _)) = member_by_node(ast, type_, *node) else {
744 continue;
745 };
746 let path = contract::join(&root, &name);
747 let Some(value) = values.field(&name) else {
748 result = Err(ProbeError::Missing { path });
749 break;
750 };
751 result = write_type(&mut sink, ast, member_type, value, frame, &path);
752 if result.is_err() {
753 break;
754 }
755 }
756 *seen = core::mem::take(&mut sink.seen);
757 result?;
758
759 if !hash {
760 return Ok(sink.w.finish());
761 }
762 Ok(match form {
763 KeyHashForm::ZeroPadded => sink.w.zero_padded().to_vec(),
764 KeyHashForm::Md5 => sink.w.md5().to_vec(),
765 })
766}
767
768/// The two crates name byte order separately — the layout planner does
769/// not depend on the runtime, and the runtime does not depend on the
770/// planner. This is the one place they meet.
771const fn wire_endianness(endianness: Endianness) -> zerodds_wire::Endianness {
772 match endianness {
773 Endianness::Little => zerodds_wire::Endianness::Little,
774 Endianness::Big => zerodds_wire::Endianness::Big,
775 }
776}
777
778fn find_type(ast: &Ast, name: &str) -> Result<Id<TypeNode>, ProbeError> {
779 let segments: Vec<String> = name
780 .split("::")
781 .filter(|segment| !segment.is_empty())
782 .map(std::string::ToString::to_string)
783 .collect();
784 ast.find_by_scoped_name(&ScopedName::absolute(segments))
785 .ok()
786 .and_then(zerodds_ast::AnyId::as_type)
787 .ok_or_else(|| ProbeError::NoSuchType {
788 name: name.to_string(),
789 })
790}
791
792/// The member with this identity, as the contract sees it.
793///
794/// `contract::members` walks `all_members_of`, so a member the
795/// aggregate inherited is found here and not skipped as if the plan had
796/// never named it.
797fn member_by_node(
798 ast: &Ast,
799 aggregate: Id<TypeNode>,
800 node: zerodds_ast_core::NodeId,
801) -> Option<(String, Id<TypeNode>, bool)> {
802 contract::members(ast, aggregate)
803 .into_iter()
804 .find(|member| member.node == node)
805 .map(|member| (member.name, member.type_, member.optional))
806}
807
808/// Write one value of one type.
809///
810/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. Every
811/// descent into an aggregate goes through `Frame::in_aggregate`, and
812/// `write_aggregate` and `write_union` refuse a value past the count with
813/// `ProbeError::ValueTooDeep`. The collection arms descend the value; the
814/// typedef arm does not, and is finite because `write_aggregate` plans the
815/// type first and `zerodds_ast_layout::plan` refuses a graph deeper than
816/// its own `MAX_DEPTH` (`crates/ast-layout/src/plan.rs:388`).
817#[allow(clippy::too_many_lines)]
818fn write_type<V: Source>(
819 s: &mut Sink,
820 ast: &Ast,
821 type_: Id<TypeNode>,
822 value: &V,
823 frame: Frame,
824 path: &str,
825) -> Result<(), ProbeError> {
826 let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
827 detail: format!("{error}"),
828 })?;
829 match &node.kind {
830 TypeKind::Primitive(kind) => write_primitive(s, *kind, value, frame.wchar, path),
831 TypeKind::String { width, bound } => {
832 write_string(s, ast, *width, *bound, value, frame, path)
833 }
834 TypeKind::Fixed { digits, scale } => {
835 write_fixed(s, *digits, *scale, frame.key.fixed_digits, value, path)
836 }
837 TypeKind::Alias { aliased } => {
838 let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
839 detail: format!("{path}: the typedef target is unresolved"),
840 })?;
841 write_type(s, ast, target, value, frame, path)
842 }
843 TypeKind::Enumeration { .. } => write_enumeration(s, ast, type_, value, frame, path),
844 TypeKind::Bitmask { .. } => write_bitmask(s, ast, type_, value, path),
845 TypeKind::Bitset { .. } => write_bitset(s, ast, type_, value, frame, path),
846 TypeKind::Sequence { element, bound } => {
847 let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
848 detail: format!("{path}: the sequence element is unresolved"),
849 })?;
850 let items = contract::bounded_items(ast, value, *bound, "elements", path)?;
851 let delimited = collection_is_delimited(ast, element, frame.key);
852 let dheader = delimited.then(|| s.w.write_dheader());
853 #[allow(clippy::cast_possible_truncation)]
854 s.w.write_u32(items.len() as u32);
855 // `SequenceElementDelimiter::BeforeEachNonFinalElement` writes
856 // an element's own octet count in front of it when the
857 // element's type is not `@final`, under both encoding
858 // versions. Measured on HDDS's `sequence-struct-appendable`
859 // and `empty-struct-in-sequence` — see the axis.
860 let carries_length = sequence_element_carries_length(ast, element, frame.key)?;
861 let packed = frame.key.element_padding == ElementPadding::Packed;
862 for (index, item) in items.iter().enumerate() {
863 let element_path = format!("{path}[{index}]");
864 let saved = packed.then(|| s.w.begin_packed_element());
865 let length = carries_length.then(|| s.w.write_dheader());
866 write_type(s, ast, element, item, frame.in_collection(), &element_path)?;
867 if let Some(length) = length {
868 s.w.try_finish_dheader(length)
869 .map_err(|error| refused(path, error))?;
870 }
871 if let Some(saved) = saved {
872 s.w.finish_packed_element(saved);
873 }
874 }
875 if let Some(dheader) = dheader {
876 s.w.try_finish_dheader(dheader)
877 .map_err(|error| refused(path, error))?;
878 }
879 Ok(())
880 }
881 TypeKind::Array { element, dims } => {
882 let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
883 detail: format!("{path}: the array element is unresolved"),
884 })?;
885 let counts = contract::array_dimensions(ast, dims, path)?;
886 let delimited = array_is_delimited(ast, element, frame.key);
887 let dheader = delimited.then(|| s.w.write_dheader());
888 write_array_dimension(s, ast, element, value, frame.in_collection(), path, &counts)?;
889 if let Some(dheader) = dheader {
890 s.w.try_finish_dheader(dheader)
891 .map_err(|error| refused(path, error))?;
892 }
893 Ok(())
894 }
895 TypeKind::Map {
896 key,
897 value: v,
898 bound,
899 } => write_map(s, ast, type_, key, v, *bound, value, frame, path),
900 TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
901 write_aggregate(s, ast, type_, value, frame, path)
902 }
903 TypeKind::Union { .. } => write_union(s, ast, type_, value, frame, path),
904 other => Err(ProbeError::Unfilled {
905 path: path.to_string(),
906 kind: other.as_str(),
907 }),
908 }
909}
910
911/// One dimension of a possibly multi-dimensional array.
912///
913/// XTypes 1.3 §7.4.3.5 treats a multi-dimensional array as one flat run
914/// with the first index varying most slowly, which is what a nested
915/// JSON array in written order already is.
916///
917/// zerodds-lint: recursion-depth 8 — one frame per array dimension.
918/// `counts` drops its head at each frame and the innermost element goes to
919/// `write_type`, which is bounded by `crate::MAX_VALUE_DEPTH`.
920fn write_array_dimension<V: Source>(
921 s: &mut Sink,
922 ast: &Ast,
923 element: Id<TypeNode>,
924 value: &V,
925 frame: Frame,
926 path: &str,
927 counts: &[usize],
928) -> Result<(), ProbeError> {
929 let Some((&count, rest)) = counts.split_first() else {
930 return write_type(s, ast, element, value, frame, path);
931 };
932 let items = contract::array(value, path)?;
933 if items.len() != count {
934 return Err(ProbeError::Mismatch {
935 path: path.to_string(),
936 expected: format!("exactly {count} elements"),
937 found: format!("{}", items.len()),
938 });
939 }
940 for (index, item) in items.iter().enumerate() {
941 // `ElementPadding::Packed`: the element begins where the
942 // previous one ended and aligns from its own first octet. See
943 // `Writer::begin_packed_element`.
944 let saved = (frame.key.element_padding == ElementPadding::Packed)
945 .then(|| s.w.begin_packed_element());
946 write_array_dimension(
947 s,
948 ast,
949 element,
950 item,
951 frame,
952 &format!("{path}[{index}]"),
953 rest,
954 )?;
955 if let Some(saved) = saved {
956 s.w.finish_packed_element(saved);
957 }
958 }
959 Ok(())
960}
961
962/// A structure or a valuetype, framed as its plan says.
963///
964/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`, checked by
965/// the `frame.within_depth` at the top of this function before any member
966/// is written.
967fn write_aggregate<V: Source>(
968 s: &mut Sink,
969 ast: &Ast,
970 type_: Id<TypeNode>,
971 value: &V,
972 frame: Frame,
973 path: &str,
974) -> Result<(), ProbeError> {
975 // Before anything is written: a value that never terminates is a
976 // cyclic object graph, which XTypes 1.3 §7.3.1.2.1.4 does not
977 // serialise, and no prefix of one is a sample.
978 frame.within_depth(path)?;
979
980 // `ElementPadding::Packed`: an aggregate value is its own alignment
981 // block. Its members count from its first octet and the aggregate is
982 // not itself padded up to its own alignment before it starts —
983 // measured on HDDS's `typedef-of-struct`, whose second `Cell` puts
984 // its `long` at offset six where the clause puts it at eight.
985 let packed = frame.key.element_padding == ElementPadding::Packed;
986 let saved_origin = packed.then(|| s.w.begin_packed_element());
987
988 let object = contract::object(value, path)?;
989
990 let mut deps = DepCollector::new();
991 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
992 detail: format!("{error}"),
993 })?;
994
995 // Every key in the value has to name a member. Checked before
996 // anything is written, so a typo is reported instead of producing
997 // a sample with a default in it.
998 let members = contract::members(ast, type_);
999 let declared: Vec<String> = members.iter().map(|member| member.name.clone()).collect();
1000 contract::reject_unknown_keys(object, &declared, path)?;
1001
1002 let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
1003
1004 // Each nested aggregate is re-planned from the top by the call
1005 // below, so the parent's plan cannot hand its members anything: the
1006 // key they are planned under has to be recomputed here from the same
1007 // rule the planner used, or the two walks disagree about what a key
1008 // holder keeps. XTypes 1.3 §7.6.8 Step 3.
1009 let frame = Frame {
1010 key: member_key(ast, type_, frame.key),
1011 ..frame.in_aggregate()
1012 };
1013
1014 // The plan names members by their stable identity and the value
1015 // file names them by their key; `Member` carries both, so the two
1016 // are matched without a second walk of the model.
1017 for planned in &layout.members {
1018 let Some(member) = members.iter().find(|member| member.node == planned.member) else {
1019 continue;
1020 };
1021 let member_path = contract::join(path, &member.name);
1022 let member_value = contract::member_value(object, member, path)?;
1023
1024 match (planned.header, member_value) {
1025 // Rule (20), §7.4.3.5.2: a BOOLEAN `<is_present>`, then the
1026 // value when there is one. One byte, not an EMHEADER, and
1027 // written whether or not the member is there — which is what
1028 // makes the gap readable. Cyclone, RTI and Fast DDS all
1029 // write exactly this; `vendor_bytes.md` §1.
1030 (MemberHeader::OptionalFlag, present) => {
1031 s.w.write_bool(present.is_some());
1032 if let Some(value) = present {
1033 write_type(s, ast, member.type_, value, frame, &member_path)?;
1034 }
1035 }
1036 // Rule (19) and §7.4.1.2: the parameter header either way,
1037 // and "if the member takes no value within that particular
1038 // object, the data length indicated by the header shall be
1039 // zero". `finish_pid` declares zero because nothing was
1040 // written between it and the header.
1041 (
1042 MemberHeader::OptionalCdr1 {
1043 pid,
1044 extended,
1045 must_understand,
1046 },
1047 present,
1048 ) => {
1049 let header = if extended {
1050 s.w.write_pid_extended(
1051 planned.member_id,
1052 ParameterRole::Optional,
1053 must_understand,
1054 )
1055 } else {
1056 s.w.write_pid(pid, ParameterRole::Optional, must_understand)
1057 }
1058 .map_err(|error| refused(&member_path, error))?;
1059 if let Some(value) = present {
1060 write_type(s, ast, member.type_, value, frame, &member_path)?;
1061 }
1062 s.w.finish_pid(header);
1063 }
1064 // An `@optional` member of a `@mutable` type that took no
1065 // value. Nothing goes out: the parameter list itself says
1066 // which members are there, so an absent one leaves no trace.
1067 // `contract::member_value` returns `None` for no other
1068 // member, so the three headers below never lose a value here.
1069 (
1070 MemberHeader::NoHeader
1071 | MemberHeader::PidCdr1 { .. }
1072 | MemberHeader::EmHeader { .. },
1073 None,
1074 ) => {}
1075 // Rule (18): the value and nothing else.
1076 (MemberHeader::NoHeader, Some(value)) => {
1077 write_type(s, ast, member.type_, value, frame, &member_path)?;
1078 }
1079 // Rules (24) and (25).
1080 (
1081 MemberHeader::PidCdr1 {
1082 pid,
1083 extended,
1084 must_understand,
1085 },
1086 Some(value),
1087 ) => {
1088 let header = if extended {
1089 s.w.write_pid_extended(
1090 planned.member_id,
1091 ParameterRole::Member,
1092 must_understand,
1093 )
1094 } else {
1095 s.w.write_pid(pid, ParameterRole::Member, must_understand)
1096 }
1097 .map_err(|error| refused(&member_path, error))?;
1098 write_type(s, ast, member.type_, value, frame, &member_path)?;
1099 s.w.finish_pid(header);
1100 }
1101 // Rule (22).
1102 (
1103 MemberHeader::EmHeader {
1104 lc,
1105 must_understand,
1106 },
1107 Some(value),
1108 ) => {
1109 let header =
1110 s.w.write_emheader(planned.member_id, length_code(lc), must_understand)
1111 .map_err(|error| refused(&member_path, error))?;
1112 write_type(s, ast, member.type_, value, frame, &member_path)?;
1113 s.w.finish_emheader(header)
1114 .map_err(|error| refused(&member_path, error))?;
1115 }
1116 }
1117 }
1118
1119 if layout.needs_sentinel {
1120 s.w.write_sentinel();
1121 }
1122 if let Some(dheader) = dheader {
1123 s.w.try_finish_dheader(dheader)
1124 .map_err(|error| refused(path, error))?;
1125 }
1126 if let Some(saved) = saved_origin {
1127 s.w.finish_packed_element(saved);
1128 }
1129 Ok(())
1130}
1131
1132/// One `MMEMBER` of a `@mutable` type, framed as its plan says.
1133///
1134/// Shared by the aggregate walk and the union walk: rules (21) to (25)
1135/// frame a structure's members and rules (27) and (28) frame a union's
1136/// discriminator and selected member the same way, so one function
1137/// writes both.
1138#[allow(clippy::too_many_arguments)]
1139fn write_mmember<V: Source>(
1140 s: &mut Sink,
1141 ast: &Ast,
1142 header: MemberHeader,
1143 member_id: u32,
1144 type_: Id<TypeNode>,
1145 value: &V,
1146 frame: Frame,
1147 path: &str,
1148) -> Result<(), ProbeError> {
1149 match header {
1150 MemberHeader::PidCdr1 {
1151 pid,
1152 extended,
1153 must_understand,
1154 } => {
1155 let header = if extended {
1156 s.w.write_pid_extended(member_id, ParameterRole::Member, must_understand)
1157 } else {
1158 s.w.write_pid(pid, ParameterRole::Member, must_understand)
1159 }
1160 .map_err(|error| refused(path, error))?;
1161 write_type(s, ast, type_, value, frame, path)?;
1162 s.w.finish_pid(header);
1163 Ok(())
1164 }
1165 MemberHeader::EmHeader {
1166 lc,
1167 must_understand,
1168 } => {
1169 let header =
1170 s.w.write_emheader(member_id, length_code(lc), must_understand)
1171 .map_err(|error| refused(path, error))?;
1172 write_type(s, ast, type_, value, frame, path)?;
1173 s.w.finish_emheader(header)
1174 .map_err(|error| refused(path, error))?;
1175 Ok(())
1176 }
1177 MemberHeader::NoHeader | MemberHeader::OptionalCdr1 { .. } | MemberHeader::OptionalFlag => {
1178 Err(ProbeError::NoLayout {
1179 detail: format!(
1180 "{path}: a `@mutable` member planned with a header that is not one"
1181 ),
1182 })
1183 }
1184 }
1185}
1186
1187/// A union: the discriminator, then the selected member.
1188///
1189/// Under `@final` and `@appendable` that is rules (26) and (30) — the
1190/// discriminator as a `NOPT_FMEMBER`, then the branch, behind a DHEADER
1191/// where the extensibility calls for one.
1192///
1193/// Under `@mutable` it is rules (27) and (28): the discriminator and the
1194/// selected member each carry an `MMEMBER` header, and under XCDR1 a
1195/// `PID_LIST_END` closes the list. This project used to write neither.
1196/// `08000000 01000000 07000000` is a DHEADER over an unframed
1197/// discriminator and member; a peer parsing it as PL_CDR2 reads
1198/// `01000000` as an EMHEADER with LC0 and hands back a one-byte member
1199/// with no error. RTI and Fast DDS both write the clause's bytes —
1200/// `vendor_bytes.md` §8.
1201///
1202/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`, checked by
1203/// the `frame.within_depth` at the top of this function.
1204fn write_union<V: Source>(
1205 s: &mut Sink,
1206 ast: &Ast,
1207 type_: Id<TypeNode>,
1208 value: &V,
1209 frame: Frame,
1210 path: &str,
1211) -> Result<(), ProbeError> {
1212 // As in `write_aggregate`: a union on a cycle nests a value just as
1213 // a structure does.
1214 frame.within_depth(path)?;
1215
1216 // As in `write_aggregate`: under `ElementPadding::Packed` a union is
1217 // its own alignment block, and `annotation-external-union-branch`
1218 // records one that starts where the previous member ended.
1219 let packed = frame.key.element_padding == ElementPadding::Packed;
1220 let saved_origin = packed.then(|| s.w.begin_packed_element());
1221
1222 let object = contract::object(value, path)?;
1223 let discriminator_value = contract::discriminator(object, path)?;
1224
1225 let mut deps = DepCollector::new();
1226 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
1227 detail: format!("{error}"),
1228 })?;
1229 let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
1230 // A union's discriminator and its branch are members, whatever the
1231 // union itself was reached as.
1232 let frame = frame.in_aggregate();
1233
1234 let TypeKind::Union { discriminator, .. } = &ast
1235 .type_(type_)
1236 .map_err(|error| ProbeError::NoLayout {
1237 detail: format!("{error}"),
1238 })?
1239 .kind
1240 else {
1241 return Err(ProbeError::Unsupported {
1242 path: path.to_string(),
1243 kind: "a union whose kind changed under the probe",
1244 });
1245 };
1246 let discriminator = discriminator
1247 .resolved()
1248 .ok_or_else(|| ProbeError::NoLayout {
1249 detail: format!("{path}: the discriminator is unresolved"),
1250 })?;
1251 let discriminator_path = contract::join(path, "_d");
1252 match layout.discriminator {
1253 None => write_type(
1254 s,
1255 ast,
1256 discriminator,
1257 discriminator_value,
1258 frame,
1259 &discriminator_path,
1260 )?,
1261 Some(header) => write_mmember(
1262 s,
1263 ast,
1264 header,
1265 0,
1266 discriminator,
1267 discriminator_value,
1268 frame,
1269 &discriminator_path,
1270 )?,
1271 }
1272
1273 // The member the value selects, by name. A union's members hang
1274 // off its cases, not off `members_of` — a case is the label list
1275 // and the member together.
1276 //
1277 // The probe does not evaluate the labels against the
1278 // discriminator: a value that names a member the discriminator
1279 // does not select is a test that wants exactly that, and refusing
1280 // it would make the divergence untestable.
1281 let cases = contract::union_cases(ast, type_);
1282
1283 // Unless a setting says the labels decide. `union_negative_matches_nothing`
1284 // is that setting's one crossing; under the default it is false for
1285 // every discriminator and this walk is the one below, unchanged.
1286 let suppress_negative = union_negative_matches_nothing(ast, type_, frame.key.union_label);
1287 if suppress_negative {
1288 write_selected_branch(
1289 s,
1290 ast,
1291 &cases,
1292 object,
1293 discriminator_value,
1294 &layout,
1295 frame,
1296 path,
1297 suppress_negative,
1298 )?;
1299 } else {
1300 for (name, member_value) in object {
1301 if name == "_d" {
1302 continue;
1303 }
1304 let case =
1305 cases
1306 .iter()
1307 .find(|case| case.name == name)
1308 .ok_or_else(|| ProbeError::Unknown {
1309 path: contract::join(path, name),
1310 declared: cases.iter().map(|case| case.name.clone()).collect(),
1311 })?;
1312 let member_path = contract::join(path, name);
1313 contract::reject_null(member_value, &member_path)?;
1314 match layout.members.iter().find(|plan| plan.member == case.node) {
1315 None => write_type(s, ast, case.type_, member_value, frame, &member_path)?,
1316 Some(plan) => write_mmember(
1317 s,
1318 ast,
1319 plan.header,
1320 plan.member_id,
1321 case.type_,
1322 member_value,
1323 frame,
1324 &member_path,
1325 )?,
1326 }
1327 }
1328 }
1329
1330 if layout.needs_sentinel {
1331 s.w.write_sentinel();
1332 }
1333 if let Some(dheader) = dheader {
1334 s.w.try_finish_dheader(dheader)
1335 .map_err(|error| refused(path, error))?;
1336 }
1337 if let Some(saved) = saved_origin {
1338 s.w.finish_packed_element(saved);
1339 }
1340 Ok(())
1341}
1342
1343/// The branch the **labels** select, and the octets a tagless union has
1344/// where they select a member the sample did not name.
1345///
1346/// Reached only under [`zerodds_ast_layout::UnionLabelMatch::NegativeLabelUnmatchedBelow32`]
1347/// and only for a signed discriminator narrower than 32 bits — the
1348/// crossing at the call site decides both, so nothing here re-reads the
1349/// policy.
1350///
1351/// Three outcomes, all three measured on Cyclone DDS 11.0.1:
1352///
1353/// - the labels select the member the sample named — write it, which is
1354/// what the walk above does anyway;
1355/// - the labels select nothing and the union declares no `default:` —
1356/// write no branch at all. `union-discriminator-{int8,short}`, four
1357/// octets shorter than the clause;
1358/// - the labels select the `default:` branch — write the union's
1359/// **storage** read through it. `combined-everything`.
1360///
1361/// # The storage
1362///
1363/// A C union puts every branch at offset zero, so the storage holds the
1364/// named member's octets in the host's order with whatever was above
1365/// them — zeros, because `wireprobe-zerodds-c-gen` declares the sample
1366/// `static`. This column has a tagged representation and no such
1367/// storage, so it is synthesised: the named member written
1368/// little-endian, zero-extended to the selected branch's extent, and
1369/// emitted as one unsigned value of that width in the stream's order.
1370///
1371/// As an unsigned integer and not as the branch's own type: the octets
1372/// are a bit pattern and a `double` that went through
1373/// `f64::from_le_bytes` and back is a value, not a pattern. A signalling
1374/// NaN pattern is the case where the two differ and no test would show
1375/// it until one did.
1376///
1377/// # What it refuses, and why nothing reaches it
1378///
1379/// Every branch has to be a primitive of one, two, four or eight
1380/// octets, and
1381/// [`zerodds_ast_layout::union_negative_matches_nothing`] already
1382/// answers `false` for a union where one is not: a `string` branch has
1383/// no storage a probe can overlay, the peer reads a pointer out of it,
1384/// and `union-member-sequence` is recorded as a segmentation fault
1385/// rather than as octets. So the refusals below are unreachable through
1386/// the crossing and are kept loud rather than removed —
1387/// `write_selected_branch` is not the place to decide that a wire
1388/// question has no answer.
1389#[allow(clippy::too_many_arguments)]
1390fn write_selected_branch<V: Source>(
1391 s: &mut Sink,
1392 ast: &Ast,
1393 cases: &[contract::Case],
1394 object: Fields<'_, V>,
1395 discriminator_value: &V,
1396 layout: &TypeLayout,
1397 frame: Frame,
1398 path: &str,
1399 suppress_negative: bool,
1400) -> Result<(), ProbeError> {
1401 // Through `contract`, which is where the value dialect lives: a
1402 // 64-bit integer arrives as a string and a narrow one as a number,
1403 // and a backend that read the document itself would accept one
1404 // spelling the other two refuse.
1405 let discriminator = contract::integer(discriminator_value, &contract::join(path, "_d"))?;
1406 let named = object.names().into_iter().find(|name| *name != "_d");
1407
1408 // Every key the sample carries still has to name a declared case:
1409 // the suppression decides which branch goes out, not whether a typo
1410 // is a typo.
1411 for name in object.names() {
1412 if name != "_d" && !cases.iter().any(|case| case.name == name) {
1413 return Err(ProbeError::Unknown {
1414 path: contract::join(path, name),
1415 declared: cases.iter().map(|case| case.name.clone()).collect(),
1416 });
1417 }
1418 }
1419
1420 let selection = contract::union_selection(ast, cases, discriminator, suppress_negative)
1421 .map_err(|unevaluated| ProbeError::Mismatch {
1422 path: path.to_string(),
1423 expected: String::from("a union whose case labels all evaluate"),
1424 found: format!(
1425 "the label of case `{}`, which the model cannot evaluate",
1426 cases
1427 .get(unevaluated.case)
1428 .map_or("<out of range>", |case| case.name.as_str())
1429 ),
1430 })?;
1431 let Some(index) = selection else {
1432 // No label matched and there is no `default:`. The discriminator
1433 // is already on the wire and nothing follows it.
1434 return Ok(());
1435 };
1436 let case = &cases[index];
1437 let member_path = contract::join(path, &case.name);
1438 let planned = layout.members.iter().find(|plan| plan.member == case.node);
1439
1440 if named == Some(case.name.as_str()) {
1441 let member_value = object.get(&case.name).ok_or_else(|| ProbeError::Missing {
1442 path: member_path.clone(),
1443 })?;
1444 contract::reject_null(member_value, &member_path)?;
1445 return match planned {
1446 None => write_type(s, ast, case.type_, member_value, frame, &member_path),
1447 Some(plan) => write_mmember(
1448 s,
1449 ast,
1450 plan.header,
1451 plan.member_id,
1452 case.type_,
1453 member_value,
1454 frame,
1455 &member_path,
1456 ),
1457 };
1458 }
1459
1460 let width = storage_width(ast, case.type_, frame.key).ok_or(ProbeError::Unsupported {
1461 path: member_path.clone(),
1462 kind: "a union branch with no fixed-width storage to overlay",
1463 })?;
1464 let octets =
1465 match named {
1466 None => vec![0_u8; width],
1467 Some(name) => {
1468 let source = cases.iter().find(|case| case.name == name).ok_or_else(|| {
1469 ProbeError::Unknown {
1470 path: contract::join(path, name),
1471 declared: cases.iter().map(|case| case.name.clone()).collect(),
1472 }
1473 })?;
1474 let source_width =
1475 storage_width(ast, source.type_, frame.key).ok_or(ProbeError::Unsupported {
1476 path: contract::join(path, name),
1477 kind: "a union branch with no fixed-width storage to overlay",
1478 })?;
1479 let mut host = Sink {
1480 w: Writer::little_endian(),
1481 seen: Consumed::new(),
1482 };
1483 let named_value = object.get(name).ok_or_else(|| ProbeError::Missing {
1484 path: contract::join(path, name),
1485 })?;
1486 write_type(
1487 &mut host,
1488 ast,
1489 source.type_,
1490 named_value,
1491 frame,
1492 &contract::join(path, name),
1493 )?;
1494 // The scratch sink read the named member out of the value
1495 // document, and that read is the sample's whatever branch
1496 // the octets end up framed as. `Consumed::absorb` says why
1497 // this is not bookkeeping.
1498 s.seen.absorb(&host.seen);
1499 let mut octets = host.w.finish();
1500 if octets.len() != source_width {
1501 return Err(ProbeError::Unsupported {
1502 path: contract::join(path, name),
1503 kind: "a union branch whose octets are not its storage",
1504 });
1505 }
1506 octets.resize(width, 0);
1507 octets
1508 }
1509 };
1510
1511 let mut pattern = [0_u8; 8];
1512 pattern[..width].copy_from_slice(&octets[..width]);
1513 let pattern = u64::from_le_bytes(pattern);
1514 #[allow(clippy::cast_possible_truncation)]
1515 fn write_pattern(s: &mut Sink, width: usize, pattern: u64) {
1516 match width {
1517 1 => s.w.write_u8(pattern as u8),
1518 2 => s.w.write_u16(pattern as u16),
1519 4 => s.w.write_u32(pattern as u32),
1520 _ => s.w.write_u64(pattern),
1521 }
1522 }
1523 // The branch that goes out, recorded as written and not as read:
1524 // where a member was named, `absorb` above has already recorded the
1525 // read, and where none was, `None => vec![0; width]` reads nothing
1526 // at all.
1527 if named.is_none() {
1528 s.seen.scalar(&member_path);
1529 }
1530 match planned {
1531 None => write_pattern(s, width, pattern),
1532 // Rules (27) and (28) still frame the branch a `@mutable` union
1533 // writes, whichever branch it is.
1534 Some(plan) => match plan.header {
1535 MemberHeader::PidCdr1 {
1536 pid,
1537 extended,
1538 must_understand,
1539 } => {
1540 let header = if extended {
1541 s.w.write_pid_extended(plan.member_id, ParameterRole::Member, must_understand)
1542 } else {
1543 s.w.write_pid(pid, ParameterRole::Member, must_understand)
1544 }
1545 .map_err(|error| refused(&member_path, error))?;
1546 write_pattern(s, width, pattern);
1547 s.w.finish_pid(header);
1548 }
1549 MemberHeader::EmHeader {
1550 lc,
1551 must_understand,
1552 } => {
1553 let header =
1554 s.w.write_emheader(plan.member_id, length_code(lc), must_understand)
1555 .map_err(|error| refused(&member_path, error))?;
1556 write_pattern(s, width, pattern);
1557 s.w.finish_emheader(header)
1558 .map_err(|error| refused(&member_path, error))?;
1559 }
1560 MemberHeader::NoHeader
1561 | MemberHeader::OptionalCdr1 { .. }
1562 | MemberHeader::OptionalFlag => {
1563 return Err(ProbeError::NoLayout {
1564 detail: format!(
1565 "{member_path}: a `@mutable` member planned with a header that is not one"
1566 ),
1567 });
1568 }
1569 },
1570 }
1571 Ok(())
1572}
1573
1574/// The octets a primitive branch occupies in a C union's storage, or
1575/// `None` for a branch that has none.
1576///
1577/// The plan's extent and not a table of widths: a `wchar` is two octets
1578/// under one framing and one plus two under another, and a branch whose
1579/// extent is not a power-of-two count of octets has no overlay.
1580fn storage_width(ast: &Ast, type_: Id<TypeNode>, key: LayoutKey) -> Option<usize> {
1581 let mut deps = DepCollector::new();
1582 let layout = plan(ast, type_, key, &mut deps).ok()?;
1583 let width = usize::try_from(layout.extent.sized()?).ok()?;
1584 matches!(width, 1 | 2 | 4 | 8).then_some(width)
1585}
1586
1587/// An enumeration, written as the integer its bit bound calls for.
1588fn write_enumeration<V: Source>(
1589 s: &mut Sink,
1590 ast: &Ast,
1591 type_: Id<TypeNode>,
1592 value: &V,
1593 frame: Frame,
1594 path: &str,
1595) -> Result<(), ProbeError> {
1596 let (name, declared) = contract::enumerator(ast, type_, value, path)?;
1597 s.seen.scalar(path);
1598
1599 // Through the crossing function and not through
1600 // `Ast::effective_bit_bound`: the holder's width is an axis, and a
1601 // site that reads the declared bound straight answers it by
1602 // omission. `zerodds_ast_layout::enumeration_bit_bound` says why.
1603 let bits = enumeration_bit_bound(ast, type_, frame.key.enum_holder);
1604 // And which integer goes into that holder is the other axis, asked
1605 // through the sibling crossing. The contract resolved the name to
1606 // the enumerator the sample means; this decides what that enumerator
1607 // serialises as. `zerodds_ast_layout::enumerator_wire_value`.
1608 // Refused and not defaulted to `declared`: under `Ordinal` the
1609 // declared value is a different integer, and writing it would put
1610 // octets on the wire that no peer under this setting produces while
1611 // reporting success. `wire_value_of` says why.
1612 let value = match wire_value_of(ast, type_, &name, frame.key.enumerator_value) {
1613 Ok(Some(value)) => value,
1614 Ok(None) | Err(StaleEnumerator) => {
1615 return Err(ProbeError::Mismatch {
1616 path: path.to_string(),
1617 expected: format!(
1618 "an enumerator whose wire value the `{}` setting can compute",
1619 frame.key.enumerator_value.as_str()
1620 ),
1621 found: format!("{name}, declared {declared}, whose wire value does not evaluate"),
1622 });
1623 }
1624 };
1625 let pattern = enumerator_pattern(value, bits).ok_or_else(|| ProbeError::Mismatch {
1626 path: path.to_string(),
1627 expected: format!("an enumerator a {bits}-bit holder can hold"),
1628 found: format!("{name} = {value}"),
1629 })?;
1630 // The mask in `enumerator_pattern` already narrowed the value to the
1631 // holder's width, so each cast below is exact.
1632 #[allow(clippy::cast_possible_truncation)]
1633 match bits {
1634 1..=8 => s.w.write_u8(pattern as u8),
1635 9..=16 => s.w.write_u16(pattern as u16),
1636 _ => s.w.write_u32(pattern),
1637 }
1638 Ok(())
1639}
1640
1641/// The integer a named enumerator of this type serialises as.
1642///
1643/// The name has already been checked against the declared set by
1644/// `contract::enumerator`; this walks the type once more to reach the
1645/// handle the crossing function takes.
1646///
1647/// # Why `None` is not answered with the declared value
1648///
1649/// It was: `wire_value_of(..).unwrap_or(declared)`. Under
1650/// [`EnumeratorValue::Declared`] that substitution is invisible because
1651/// the declared value *is* the answer; under
1652/// [`EnumeratorValue::Ordinal`] it writes a different integer, and the
1653/// probe reports success over octets no peer under that setting would
1654/// produce. `enumerator_wire_value` refuses to substitute a zero for
1655/// exactly this reason — substituting the declared value is the same
1656/// failure with a different substitute, and it is harder to see because
1657/// it is right half the time. The caller refuses instead.
1658///
1659/// A stale handle inside the walk is not skipped either: skipping it
1660/// would make the enumerator the sample names unreachable and hand the
1661/// walk's `None` back as though the name were undeclared, which
1662/// `contract::enumerator` has already ruled out.
1663fn wire_value_of(
1664 ast: &Ast,
1665 type_: Id<TypeNode>,
1666 name: &str,
1667 policy: EnumeratorValue,
1668) -> Result<Option<i128>, StaleEnumerator> {
1669 for id in ast.enumerators_of(type_) {
1670 let node = ast.enumerator(*id).map_err(|_| StaleEnumerator)?;
1671 if node.name == name {
1672 return Ok(enumerator_wire_value(ast, *id, policy));
1673 }
1674 }
1675 Ok(None)
1676}
1677
1678/// An enumerator handle `enumerators_of` produced and the arena no
1679/// longer holds.
1680///
1681/// A unit type rather than a message, because the caller is the only
1682/// place that knows the path and the enumerator's name.
1683struct StaleEnumerator;
1684
1685/// The bit pattern an enumerator's value has in its holder, or `None`
1686/// where the value does not fit the bound.
1687///
1688/// XTypes 1.3 §7.4.3.5.3 rule (5) is `XCDR << {O : ENUM_TYPE} = XCDR <<
1689/// { O.value : O.holder_type }`, and the ENUM_TYPE entry in the same
1690/// clause's table says "O.holder_type is either Int8, Int16 or Int32
1691/// depending on the value of the @bit_bound annotation". A **signed**
1692/// holder: `@value(-1)` is the two's complement of one, so at the
1693/// default bound it is `ffffffff`. Clamping it to zero, as
1694/// `u32::try_from(value).unwrap_or(0)` did, wrote a different
1695/// enumerator's value and reported success.
1696///
1697/// # What fits
1698///
1699/// The bound is a width, and the two implementations that accept these
1700/// constructs read it as one. Measured on 2026-08-09:
1701///
1702/// - RTI Connext 7.7 takes `@value(-1)` at the default bound and writes
1703/// `ffffffff`. Cyclone's `idlc` refuses the annotation outright
1704/// ("@value(-1) cannot be applied to 'enumerator' element"), so RTI is
1705/// the only oracle for a negative value.
1706/// - Cyclone takes `@value(200)` under `@bit_bound(8)` and writes `c8`.
1707/// RTI refuses any enum bound but 32, so Cyclone is the only oracle
1708/// for a narrow one.
1709///
1710/// Between them the accepted range is everything expressible in the
1711/// bound's bits, signed or unsigned: `-2^(n-1) ..= 2^n - 1`. A stricter
1712/// signed-only range would reject `@value(200)`, which Cyclone writes;
1713/// a wider one would accept values neither implementation can express.
1714///
1715/// §7.3.1.2.1.5 caps the bound itself: "the value member of the
1716/// annotation may take any value from 1 to 32, inclusive, when this
1717/// annotation is applied to an enumerated type." A bound outside that
1718/// has no holder to be written into, so it is refused rather than
1719/// widened to eight bytes.
1720fn enumerator_pattern(value: i128, bits: u16) -> Option<u32> {
1721 if bits == 0 || bits > 32 {
1722 return None;
1723 }
1724 let width = i128::from(bits);
1725 if value < -(1i128 << (width - 1)) || value > (1i128 << width) - 1 {
1726 return None;
1727 }
1728 let mask = (1u128 << width) - 1;
1729 u32::try_from((value as u128) & mask).ok()
1730}
1731
1732/// A `string<N>` or `wstring<N>`, refusing a value the bound cannot hold.
1733///
1734/// The bytes are the same whether the string is bounded or not — the
1735/// bound is a promise about the source and not a difference on the wire
1736/// — so this is the only place it can be enforced. It was not enforced
1737/// anywhere: `let _ = bound;` sat here under a comment claiming the
1738/// front end caught an overrun, and the front end never sees the value.
1739/// Eight characters went into a `string<4>` and the probe said nothing,
1740/// which is the one thing a probe must not do.
1741///
1742/// # What the bound counts
1743///
1744/// Octets for `string<N>` and UTF-16 code units for `wstring<N>`, which
1745/// is what the receiving side has room for: §7.5.1.2.2 maps `string<5>`
1746/// to `char[6]` and `wstring<5>` to a five-unit buffer in every measured
1747/// implementation's C mapping, so a five-scalar value of ten octets is
1748/// one no peer can hold. `runtime/python/zerodds_wire/typesupport.py`
1749/// counts the same two ways — `utf8_octets` and `utf16_units` — and the
1750/// two columns have to agree on which values they refuse.
1751///
1752/// The NUL is not counted. §7.4.1.1.2 puts it on the wire for a narrow
1753/// string and not in the bound: `string<5>` holds five characters.
1754fn write_string<V: Source>(
1755 s: &mut Sink,
1756 ast: &Ast,
1757 width: CharWidth,
1758 bound: Bound,
1759 value: &V,
1760 frame: Frame,
1761 path: &str,
1762) -> Result<(), ProbeError> {
1763 let text = contract::text(value, path)?;
1764 s.seen.scalar(path);
1765 if let Some(limit) = bound.expr().and_then(|expr| ast.eval_integral(expr).ok()) {
1766 let (count, noun) = match width {
1767 CharWidth::Char => (text.len(), "octets"),
1768 CharWidth::WChar => (text.chars().map(utf16_units).sum(), "UTF-16 code units"),
1769 };
1770 if i128::try_from(count).unwrap_or(i128::MAX) > limit {
1771 return Err(ProbeError::Mismatch {
1772 path: path.to_string(),
1773 expected: format!("at most {limit} {noun}, which is what the bound holds"),
1774 found: format!("{count}"),
1775 });
1776 }
1777 }
1778 match width {
1779 CharWidth::Char => {
1780 s.w.write_string(text);
1781 Ok(())
1782 }
1783 CharWidth::WChar => {
1784 if frame.key.wstring_code_units == WstringCodeUnits::Utf8 {
1785 write_wstring_utf8(
1786 &mut s.w,
1787 text,
1788 frame.key.wstring_length,
1789 layout_position(frame.wchar),
1790 path,
1791 )
1792 } else {
1793 write_wstring(
1794 &mut s.w,
1795 text,
1796 frame.key.wstring_length,
1797 frame.key.wstring_unit_order,
1798 // The position the `wstring` is reached from. Passed and
1799 // not assumed, for the reason `read_wstring` gives.
1800 layout_position(frame.wchar),
1801 path,
1802 )
1803 }
1804 }
1805 }
1806}
1807
1808/// How many UTF-16 code units one scalar takes.
1809///
1810/// Two above the Basic Multilingual Plane, which is a surrogate pair —
1811/// §7.4.1.1.2's own rationale for counting bytes rather than characters.
1812const fn utf16_units(character: char) -> usize {
1813 if (character as u32) > 0xFFFF { 2 } else { 1 }
1814}
1815
1816/// A `wstring`: a four-byte length, then UTF-16 code units.
1817///
1818/// XTypes 1.3 §7.4.3.5.3 rule (4) writes `O.ssize` as a `UInt32` and
1819/// then the `Char16` units, annotated "No NUL" against rule (3)'s
1820/// "includes NUL" for a narrow string. Two things follow, and only the
1821/// first is visible in the rule.
1822///
1823/// What `O.ssize` counts is settled in §7.4.1.1.2: "The serialized
1824/// length of an object of type String<Char16> shall be the number of
1825/// bytes in the CDR buffer taken by the String<Char16> characters. This
1826/// is twice the number of characters in the string". Code units, not
1827/// characters — the clause's own rationale gives the reason, that a
1828/// code point outside the Basic Multilingual Plane takes two UTF-16
1829/// units and a byte count still reaches the end of the string. So a
1830/// `"𝄞"` is a length of four and not of one or two.
1831///
1832/// The same clause forbids the two things a UTF-16 encoder would
1833/// otherwise emit: "The UTF-16 representation ... shall not include a
1834/// Byte Order Mark (BOM). The representation shall also not include any
1835/// terminating NUL character(s)." The order the units go out in is the
1836/// stream's, which is what the encapsulation identifier already
1837/// announced.
1838///
1839/// # The other two forms are written on request
1840///
1841/// Cyclone writes the clause above under both versions and RTI writes
1842/// it under XCDR2; RTI's XCDR1 and Fast DDS's output are two other
1843/// things, and [`WstringLengthPolicy`] carries the measurement and the
1844/// argument. Only the count and the character width change here; the
1845/// text is the same text.
1846/// The layout crate's name for a byte-layer position.
1847///
1848/// The two enumerations are deliberately separate — `zerodds-wire`
1849/// knows nothing about a dialect — and this is the one place the walk
1850/// crosses between them. A `match` and not a cast: a position added to
1851/// either side has to be given a meaning here rather than silently
1852/// becoming `Member`, which is precisely how the framing defect
1853/// survived five emitters.
1854const fn layout_position(position: WcharPosition) -> WirePosition {
1855 match position {
1856 WcharPosition::Member => WirePosition::Member,
1857 WcharPosition::Element => WirePosition::Element,
1858 WcharPosition::Sentinel => WirePosition::Sentinel,
1859 WcharPosition::ExtendedMarker => WirePosition::ExtendedMarker,
1860 }
1861}
1862
1863fn write_wstring(
1864 w: &mut Writer,
1865 text: &str,
1866 policy: WstringLengthPolicy,
1867 order: WstringUnitOrder,
1868 position: WirePosition,
1869 path: &str,
1870) -> Result<(), ProbeError> {
1871 // The coordinates are asked separately because they are separate:
1872 // C1 says what the length counts, C2 whether a terminator joins the
1873 // run, C3 how wide a unit is, and C4 — on its own axis now — in
1874 // what order each one lands. Folding them together is what produced
1875 // `UnswappedUnits`, a byte order wearing a length policy's name,
1876 // and it is what left the cell both ROS type supports write with no
1877 // name at all.
1878 let mut units: Vec<u16> =
1879 if matches!(policy.counts_at(position), WstringLengthCounts::Characters)
1880 && policy.unit_at(position) == WstringUnitWidth::Two
1881 {
1882 // Fast CDR counts `wcslen` characters and then writes each
1883 // four-byte `wchar_t` as two bytes. A code point outside the
1884 // BMP loses its high bits there: `U+1D11E` goes out as `d11e`,
1885 // which is `U+D11E`, a Hangul syllable and a well-formed code
1886 // point. The peer reads a different character and has nothing
1887 // to object to. Reproducing a vendor's length convention is a
1888 // policy; reproducing a data loss no reader can see is not, so
1889 // this refuses.
1890 //
1891 // The refusal is on the pair (characters, two octets) and not
1892 // on a named form: it is the narrow unit that loses the high
1893 // bits, and a character count over four-octet units — the ROS
1894 // cell — carries the surrogate pair intact and is not refused.
1895 if text.chars().any(|c| c as u32 > 0xFFFF) {
1896 return Err(ProbeError::Unsupported {
1897 path: path.to_string(),
1898 kind: concat!(
1899 "a code point outside the BMP under a character count over two-octet ",
1900 "units, which has no way to carry it — Fast CDR truncates it to ",
1901 "sixteen bits and sends a different character"
1902 ),
1903 });
1904 }
1905 #[allow(clippy::cast_possible_truncation)]
1906 text.chars().map(|c| c as u32 as u16).collect()
1907 } else {
1908 text.encode_utf16().collect()
1909 };
1910 if matches!(policy.terminator_at(position), WstringTerminator::Present) {
1911 // CORBA §9.3.1.6's GIOP 1.1 rule, and RTI's XCDR1 form. The NUL
1912 // is a unit of the run and is inside whatever the length counts.
1913 units.push(0);
1914 }
1915
1916 // The byte-order mark is a code unit like any other and its octets
1917 // are counted in the length: omniORB writes `0a000000 fffe 6100
1918 // 34d8 1edd 6200` for `"a𝄞b"`, ten octets over five units.
1919 // §7.4.1.1.2 forbids it outright — "shall not include a Byte Order
1920 // Mark" — so it is here only under `ByteOrderMark`, which no DDS
1921 // dialect selects.
1922 // An **empty** `wstring` carries no mark: omniORB writes `00000000`
1923 // for one, measured 2026-08-10 as `wstring-unbounded-empty`, which
1924 // is byte-identical to every other implementation. A mark on an
1925 // empty string would be a two-octet string that decodes to nothing,
1926 // and no peer writes it.
1927 if order.has_mark() && !units.is_empty() {
1928 units.insert(0, BYTE_ORDER_MARK);
1929 }
1930
1931 let width = policy.character_width(position);
1932 #[allow(clippy::cast_possible_truncation)]
1933 // C1, over units that already include the terminator and the mark.
1934 // An octet count multiplies by C3; a character count is the run's
1935 // length. One expression rather than an arm per named form, so a
1936 // cell nobody has measured computes its length the same way.
1937 let declared = match policy.counts_at(position) {
1938 WstringLengthCounts::Octets => (units.len() * width) as u32,
1939 WstringLengthCounts::Characters => units.len() as u32,
1940 };
1941 w.write_u32(declared);
1942
1943 for unit in units {
1944 write_wstring_unit(w, unit, width, policy, order);
1945 }
1946 Ok(())
1947}
1948
1949/// A `wstring` whose characters go out as their own UTF-8 octets.
1950///
1951/// [`WstringCodeUnits::Utf8`] is the measured departure of one product's
1952/// generated serialiser. XTypes 1.3 Table 31 gives a `wstring`'s code
1953/// unit as `Char16` and §7.4.1.1.2 makes the length "twice the number
1954/// of characters", so the clause has no UTF-8 form at all; the axis
1955/// carries the measurement and the recorded rows.
1956///
1957/// The length is the UTF-8 octet count, not a count of anything a
1958/// two-octet unit could be multiplied by — under this value C1 and C3
1959/// have nothing to say (see [`WstringCodeUnits::counts_octets`]) and
1960/// only the terminator still reaches the wire. `wstring-member` is
1961/// `04 00 00 00 61 62 63 00` for `"abc"`: three octets, a NUL, and the
1962/// four declares all of it.
1963fn write_wstring_utf8(
1964 w: &mut Writer,
1965 text: &str,
1966 policy: WstringLengthPolicy,
1967 position: WirePosition,
1968 path: &str,
1969) -> Result<(), ProbeError> {
1970 let bytes = text.as_bytes();
1971 let terminator = matches!(policy.terminator_at(position), WstringTerminator::Present);
1972 let content = bytes.len() + usize::from(terminator);
1973 let declared = u32::try_from(content).map_err(|_| ProbeError::Unsupported {
1974 path: path.to_string(),
1975 kind: "a wstring whose UTF-8 octet count fits the UInt32 length field",
1976 })?;
1977 w.write_u32(declared);
1978 for byte in bytes {
1979 w.write_u8(*byte);
1980 }
1981 if terminator {
1982 w.write_u8(0);
1983 }
1984 Ok(())
1985}
1986
1987/// Whether an element of a `sequence` is preceded by its own octet
1988/// length under this key.
1989///
1990/// [`SequenceElementDelimiter::BeforeEachNonFinalElement`] is the
1991/// measured departure: a four-octet `UInt32` in front of every element
1992/// whose type is not `@final`, under both encoding versions. A type
1993/// with no extensibility of its own — a primitive, a string, an
1994/// enumeration, a `typedef` — is `@final` by construction and takes
1995/// none; only the four aggregate kinds can be `@appendable` or
1996/// `@mutable`.
1997///
1998/// The alias is **not** seen through, and that is the measurement.
1999/// `recursive-typedef-sequence` is `sequence<NodeRef>` with
2000/// `typedef Node NodeRef` over an `@appendable Node`, and HDDS writes
2001/// no element length: its generated sequence encoder decides on the
2002/// declared element type, and a `typedef` carries no `@final`/
2003/// `@appendable` of its own. A `sequence<Later>` of a forward-declared
2004/// structure, and a `sequence<Node>` of the structure itself, both carry
2005/// it.
2006///
2007/// # Errors
2008/// [`ProbeError::NoLayout`] for an element id the model does not hold:
2009/// whether the element carries a length cannot be told, and guessing
2010/// either way frames every element of the sequence wrong.
2011pub(crate) fn sequence_element_carries_length(
2012 ast: &Ast,
2013 element: Id<TypeNode>,
2014 key: LayoutKey,
2015) -> Result<bool, ProbeError> {
2016 zerodds_ast_layout::xcdr::sequence_element_carries_length(ast, element, key).ok_or_else(|| {
2017 ProbeError::NoLayout {
2018 detail: format!(
2019 "the sequence element {element:?} is not a type the model holds, so whether it \
2020 carries its own length cannot be told"
2021 ),
2022 }
2023 })
2024}
2025
2026/// The UTF-16 byte-order mark, `U+FEFF`.
2027///
2028/// Named because it appears on both sides of the wire and a bare
2029/// `0xFEFF` in a reader would read as a character of the string.
2030const BYTE_ORDER_MARK: u16 = 0xFEFF;
2031
2032/// One `wstring` code unit, at its width and in its order.
2033///
2034/// The order and the length convention are separate settings and this is
2035/// where the two meet, once, so that a form cannot be written one way in
2036/// the length field and another in the content.
2037fn write_wstring_unit(
2038 w: &mut Writer,
2039 unit: u16,
2040 width: usize,
2041 policy: WstringLengthPolicy,
2042 order: WstringUnitOrder,
2043) {
2044 // C3 picks the writer and C4 picks its endianness. There is no
2045 // longer an arm for a length policy that decides a byte order:
2046 // `HostUnswapped` is a point of the order axis, so Cyclone's form
2047 // and `rosidl_typesupport_fastrtps`'s are the same answer at two
2048 // different widths rather than two unrelated special cases.
2049 let _ = policy;
2050 match (width, order) {
2051 (2, WstringUnitOrder::Stream) => w.write_u16(unit),
2052 (2, WstringUnitOrder::BigEndianAlways) => w.write_u16_big(unit),
2053 (2, WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped) => {
2054 w.write_u16_little(unit);
2055 }
2056 (_, WstringUnitOrder::Stream) => w.write_u32(u32::from(unit)),
2057 (_, WstringUnitOrder::BigEndianAlways) => w.write_u32_big(u32::from(unit)),
2058 (_, WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped) => {
2059 w.write_u32_little(u32::from(unit));
2060 }
2061 }
2062}
2063
2064/// A bitmask, written as the unsigned integer its bit bound calls for.
2065///
2066/// XTypes 1.3 §7.4.3.5.3 rule (6) writes `O.value` as
2067/// `O.holder_type`, and Table 33 of §7.4.1.1.3.2 gives the holder:
2068/// `UInt8` for a bound of 1 to 8, `UInt16` to 16, `UInt32` to 32 and
2069/// `UInt64` to 64. The same clause fixes the direction — "Bit indexes
2070/// are counted from zero starting at the least-significant bit of the
2071/// full byte size of the bitmask" — so bit `n` is `1 << n` in the
2072/// holder whatever the holder's width.
2073///
2074/// A position at or past the bound is refused. §7.4.1.1.3.2 says the
2075/// bits above the bound "are not considered to be part of the
2076/// bitmask", so setting one would put a value on the wire that no peer
2077/// is required to read back.
2078fn write_bitmask<V: Source>(
2079 s: &mut Sink,
2080 ast: &Ast,
2081 type_: Id<TypeNode>,
2082 value: &V,
2083 path: &str,
2084) -> Result<(), ProbeError> {
2085 let set = contract::bit_names(ast, type_, value, path)?;
2086 let bound = ast.effective_bit_bound(type_);
2087
2088 let mut bits: u64 = 0;
2089 for (index, (_, position)) in set.iter().enumerate() {
2090 s.seen.scalar(&format!("{path}[{index}]"));
2091 bits |= 1u64 << position;
2092 }
2093
2094 // `contract::bit_names` refused a bound outside 1..=64, so each
2095 // narrowing below is exact for the bits that survived it.
2096 match bound {
2097 1..=8 => s.w.write_u8(u8::try_from(bits).unwrap_or(0)),
2098 9..=16 => s.w.write_u16(u16::try_from(bits).unwrap_or(0)),
2099 17..=32 => s.w.write_u32(u32::try_from(bits).unwrap_or(0)),
2100 _ => s.w.write_u64(bits),
2101 }
2102 Ok(())
2103}
2104
2105/// A bitset, written as the anchor integer its bitfields pack into.
2106///
2107/// # The specification does not say
2108///
2109/// DDS-XTypes 1.3 has no serialisation rule for a bitset. §7.4.3.5.3
2110/// runs from rule (1) to rule (30) and no left-hand side matches one;
2111/// §7.4.1.1 covers strings, enumerations, bitmasks, maps and structures
2112/// and stops. The only normative text about bitsets at all is the
2113/// TypeObject IDL's `CommonBitfield { unsigned short position;
2114/// BitsetMemberFlag flags; octet bitcount; TypeKind holder_type; }`
2115/// with its `// Ordered by Bitfield.position`.
2116///
2117/// **There is no clause number to cite for the wire form, and this
2118/// comment used to carry a wrong one.** It said §7.3.4.6, which is
2119/// "Classification of TypeIdentifiers" and says nothing about bitsets.
2120/// A wrong citation is worse than none: the next reader follows it,
2121/// finds text that does not support the claim, and has to work out
2122/// whether the claim or the pointer is broken.
2123/// `docs/refused-constructs.md`, "bitset", walks the whole document and
2124/// records where every rule this file follows actually comes from.
2125///
2126/// Two things are therefore taken from elsewhere and are marked as such
2127/// in the tests:
2128///
2129/// - **The positions.** `CommonBitfield.position` has to come from
2130/// somewhere, and the only reading under which an IDL bitset
2131/// round-trips through a TypeObject is that positions run
2132/// cumulatively from zero in declaration order, anonymous padding
2133/// fields included. That is the rule
2134/// `crates/xml/src/typeobject_bridge.rs` already fills the field by.
2135/// - **The anchor.** How wide the integer holding those bits is comes
2136/// from the layout planner, `zerodds_ast_layout::plan`, and is not
2137/// decided a second time here — the same reason the rest of this
2138/// module reads the plan rather than the model.
2139///
2140/// - **Inheritance.** A bitset that inherits takes the base's bitfields
2141/// at the low positions, in declaration order, before its own — the
2142/// same rule §7.4.1.1.5.1 states for an inherited structure. Fast DDS
2143/// is the only implementation that parses `bitset Derived : Base`
2144/// (Cyclone's `idlc` and RTI's `rtiddsgen` reject the keyword), and it
2145/// flattens the chain into one `std::bitset<N>` filled base-first. The
2146/// measurement is in `docs/refused-constructs.md`; the whole-chain
2147/// width comes from the planner through [`Ast::all_bitfields_of`].
2148fn write_bitset<V: Source>(
2149 s: &mut Sink,
2150 ast: &Ast,
2151 type_: Id<TypeNode>,
2152 value: &V,
2153 frame: Frame,
2154 path: &str,
2155) -> Result<(), ProbeError> {
2156 let fields = ast.all_bitfields_of(type_);
2157 let object = contract::object(value, path)?;
2158
2159 let mut deps = DepCollector::new();
2160 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2161 detail: format!("{error}"),
2162 })?;
2163 let Extent::Sized(anchor) = layout.extent else {
2164 return Err(ProbeError::Unsupported {
2165 path: path.to_string(),
2166 kind: "a bitset whose anchor the planner does not size",
2167 });
2168 };
2169
2170 let declared = contract::bitfield_names(ast, &fields);
2171 contract::reject_unknown_keys(object, &declared, path)?;
2172
2173 let mut bits: u64 = 0;
2174 let mut position: u32 = 0;
2175 for id in &fields {
2176 let Ok(field) = ast.bitfield(*id) else {
2177 continue;
2178 };
2179 let width = u32::from(field.bits);
2180 if position.saturating_add(width) > 64 {
2181 return Err(ProbeError::Unsupported {
2182 path: path.to_string(),
2183 kind: "a bitset wider than the sixty-four bits an anchor holds",
2184 });
2185 }
2186 // An anonymous field is padding: it moves the next field along
2187 // and carries no value of its own.
2188 if let Some(name) = field.name.as_ref() {
2189 let member_path = contract::join(path, name);
2190 let Some(member_value) = object.get(name.as_str()) else {
2191 return Err(ProbeError::Missing { path: member_path });
2192 };
2193 let raw = contract::integer(member_value, &member_path)?;
2194 s.seen.scalar(&member_path);
2195 // A value wider than its bitfield would overwrite the field
2196 // above it. Refused rather than masked: a mask puts a
2197 // different number on the wire than the sample asked for.
2198 let limit = 1i128 << width;
2199 if raw < 0 || raw >= limit {
2200 return Err(ProbeError::Mismatch {
2201 path: member_path,
2202 expected: format!("an unsigned value below {limit}, for bitfield<{width}>"),
2203 found: format!("{raw}"),
2204 });
2205 }
2206 #[allow(clippy::cast_sign_loss)]
2207 let raw = raw as u64;
2208 bits |= raw << position;
2209 }
2210 position += width;
2211 }
2212
2213 match anchor {
2214 1 => s.w.write_u8(u8::try_from(bits).unwrap_or(0)),
2215 2 => s.w.write_u16(u16::try_from(bits).unwrap_or(0)),
2216 4 => s.w.write_u32(u32::try_from(bits).unwrap_or(0)),
2217 8 => s.w.write_u64(bits),
2218 _ => {
2219 return Err(ProbeError::Unsupported {
2220 path: path.to_string(),
2221 kind: "a bitset whose anchor is not one, two, four or eight bytes",
2222 });
2223 }
2224 }
2225 Ok(())
2226}
2227
2228/// A map: the entries, in the shape §7.4.1.1.4 says a map has.
2229///
2230/// §7.4.1.1.4 defines a map as equivalent to a
2231/// `sequence<MapEntry_<key_type>_<value_type>>`, and §7.4.3.5.3 rules
2232/// (14), (15) and (16) write the key and the value of each entry
2233/// consecutively with no framing of their own. The rules are followed
2234/// rather than the equivalence on that point: read literally, the
2235/// `MapEntry` struct in §7.4.1.1.4 carries no extensibility annotation
2236/// and would default to appendable under §7.2.4.4, which would put a
2237/// DHEADER in front of every entry. No rule writes one.
2238///
2239/// Whether the map itself is delimited is the planner's decision and is
2240/// read off the plan, not recomputed: `zerodds_ast_layout` sets
2241/// `needs_dheader` from rule (14), which exempts a map only when both
2242/// halves are primitive, and rule (15), which delimits the rest under
2243/// XCDR2. All of Fast DDS, OpenDDS and this project write `map<long,
2244/// long>` as a bare count and the pairs, so the exemption is measured.
2245/// Where Fast DDS goes further and exempts `map<string, long>` as well
2246/// — its criterion is the value type alone — the broader reading is
2247/// taken here: a string key defeats the clause's own rationale for the
2248/// exemption, that the size can be determined "without iterating over
2249/// each element". OpenDDS reads it the same way.
2250///
2251/// Whether a delimited map also carries an entry count is the one thing
2252/// the specification says twice and differently, and the two
2253/// implementations that can be asked took one reading each; see
2254/// [`MapLengthPolicy`].
2255///
2256/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. The
2257/// entries go back to `write_type`, whose aggregate arms count and refuse.
2258#[allow(clippy::too_many_arguments)]
2259fn write_map<V: Source>(
2260 s: &mut Sink,
2261 ast: &Ast,
2262 type_: Id<TypeNode>,
2263 key_type: &Ref<TypeNode>,
2264 value_type: &Ref<TypeNode>,
2265 bound: Bound,
2266 value: &V,
2267 frame: Frame,
2268 path: &str,
2269) -> Result<(), ProbeError> {
2270 let key_type = key_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2271 detail: format!("{path}: the map key type is unresolved"),
2272 })?;
2273 let value_type = value_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2274 detail: format!("{path}: the map value type is unresolved"),
2275 })?;
2276
2277 // An array of pairs and not an object: a JSON object keys on a
2278 // string, and `map<long, float>` does not.
2279 let entries = contract::bounded_items(ast, value, bound, "entries", path)?;
2280
2281 let mut deps = DepCollector::new();
2282 let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2283 detail: format!("{error}"),
2284 })?;
2285 let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
2286 if dheader.is_none() || frame.map_length == MapLengthPolicy::SequenceEquivalent {
2287 #[allow(clippy::cast_possible_truncation)]
2288 s.w.write_u32(entries.len() as u32);
2289 }
2290
2291 for index in map_entry_order(ast, type_, key_type, entries, frame, path)? {
2292 let entry = &entries[index];
2293 let entry_path = format!("{path}[{index}]");
2294 let (entry_key, entry_value) = contract::map_entry(entry, &entry_path)?;
2295 write_type(
2296 s,
2297 ast,
2298 key_type,
2299 entry_key,
2300 frame.in_collection(),
2301 &contract::join(&entry_path, "key"),
2302 )?;
2303 write_type(
2304 s,
2305 ast,
2306 value_type,
2307 entry_value,
2308 frame.in_collection(),
2309 &contract::join(&entry_path, "value"),
2310 )?;
2311 }
2312
2313 if let Some(dheader) = dheader {
2314 s.w.try_finish_dheader(dheader)
2315 .map_err(|error| refused(path, error))?;
2316 }
2317 Ok(())
2318}
2319
2320/// The indices of a map's entries, in the order they go on the wire.
2321///
2322/// `0..len` under [`MapEntryOrder::Declaration`], which is every
2323/// dialect but Fast DDS and OpenDDS; a permutation sorted by the key's
2324/// own value under [`MapEntryOrder::AscendingKey`], which is those two.
2325///
2326/// [`zerodds_ast_layout::map_key_ordering`] is the crossing and this is
2327/// its only caller in the Rust column. Where the policy asks for
2328/// ascending keys and the key type has no order, the answer is a
2329/// **refusal** and not the declaration order: a setting that silently
2330/// did nothing for one key type and something for another would be a
2331/// flag that reports success and changed no octet.
2332fn map_entry_order<V: Source>(
2333 ast: &Ast,
2334 type_: Id<TypeNode>,
2335 key_type: Id<TypeNode>,
2336 entries: &[V],
2337 frame: Frame,
2338 path: &str,
2339) -> Result<Vec<usize>, ProbeError> {
2340 if frame.key.map_entries.is_default() {
2341 return Ok((0..entries.len()).collect());
2342 }
2343 let Some(ordering) = map_key_ordering(ast, type_, frame.key.map_entries) else {
2344 return Err(ProbeError::Unsupported {
2345 path: path.to_string(),
2346 kind: "a map whose key type has no value order, under --map-entries ascending-key",
2347 });
2348 };
2349 let mut keyed = Vec::with_capacity(entries.len());
2350 for (index, entry) in entries.iter().enumerate() {
2351 let entry_path = format!("{path}[{index}]");
2352 let (entry_key, _) = contract::map_entry(entry, &entry_path)?;
2353 let sort_key = map_sort_key(
2354 ast,
2355 key_type,
2356 ordering,
2357 entry_key,
2358 &contract::join(&entry_path, "key"),
2359 )?;
2360 keyed.push((sort_key, index));
2361 }
2362 // By the key and then by the declared position, so that a sample
2363 // with two entries carrying the same key keeps their order rather
2364 // than taking whichever one the sort happened to move.
2365 keyed.sort();
2366 Ok(keyed.into_iter().map(|(_, index)| index).collect())
2367}
2368
2369/// One map key reduced to something with an order.
2370///
2371/// Three shapes and not one: an integer order is not an octet order —
2372/// `-1234567` encodes as `ffed2979` and sorts first by value and last by
2373/// octet — and a string's order is over its octets.
2374#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2375enum MapSortKey {
2376 /// Every scalar key: the value, signed or unsigned as the key type
2377 /// says, widened to `i128` so that both fit one comparison.
2378 Integer(i128),
2379 /// A `string` or `wstring` key: the octets of its UTF-8, compared
2380 /// unsigned. UTF-8 is order preserving, so this is also the
2381 /// code-point order a `std::wstring` compares in.
2382 CodeUnits(Vec<u8>),
2383}
2384
2385/// The sort key of one map key value.
2386fn map_sort_key<V: Source>(
2387 ast: &Ast,
2388 key_type: Id<TypeNode>,
2389 ordering: MapKeyOrdering,
2390 value: &V,
2391 path: &str,
2392) -> Result<MapSortKey, ProbeError> {
2393 let resolved = contract::resolve_alias(ast, key_type);
2394 let kind = ast.type_(resolved).map(|node| node.kind.clone()).ok();
2395 match ordering {
2396 MapKeyOrdering::EnumeratorValue => {
2397 let (_, declared) = contract::enumerator(ast, resolved, value, path)?;
2398 Ok(MapSortKey::Integer(declared))
2399 }
2400 MapKeyOrdering::CodeUnits => Ok(MapSortKey::CodeUnits(
2401 contract::text(value, path)?.as_bytes().to_vec(),
2402 )),
2403 MapKeyOrdering::SignedInteger | MapKeyOrdering::UnsignedInteger => {
2404 let raw = match kind {
2405 Some(TypeKind::Primitive(PrimitiveKind::Boolean)) => {
2406 i128::from(contract::boolean(value, path)?)
2407 }
2408 Some(TypeKind::Primitive(kind @ (PrimitiveKind::Char | PrimitiveKind::WChar))) => {
2409 i128::from(u32::from(contract::character_for(kind, value, path)?))
2410 }
2411 Some(TypeKind::Primitive(kind)) => contract::integer_for(kind, value, path)?,
2412 _ => contract::integer(value, path)?,
2413 };
2414 // An IDL `char` is a C++ `char`, and `char` is signed on the
2415 // host the corpus was recorded on. Written out here rather
2416 // than left to the column's own compiler, so that the three
2417 // columns agree on a host whose `char` is unsigned.
2418 let signed = ordering == MapKeyOrdering::SignedInteger
2419 && matches!(kind, Some(TypeKind::Primitive(PrimitiveKind::Char)))
2420 && raw >= 0x80;
2421 Ok(MapSortKey::Integer(if signed { raw - 0x100 } else { raw }))
2422 }
2423 }
2424}
2425
2426/// `fixed<P, S>`: the IDL fixed-point decimal, as packed BCD.
2427///
2428/// # The specification does not say
2429///
2430/// DDS-XTypes 1.3 does not carry the type. §7.2.2 has no fixed-point
2431/// kind, the IDL-to-type-system table of §7.3.1 does not map one, and
2432/// §7.4.3.5.3 has no rule whose left-hand side matches. Searching the
2433/// 1.3 text for "fixed" returns only prose about fixed-*size*
2434/// collections. A DDS peer is therefore not required to read this at
2435/// all, and the tests mark the bytes as unverified against any DDS
2436/// vendor.
2437///
2438/// It is nonetheless measured. `fixed<P,S>` came from CORBA and two
2439/// CORBA ORBs still write it: omniORB 4.3.3 produces `12345c` for a
2440/// `fixed<5,2>` holding `123.45`, byte for byte what this function
2441/// writes, over nine shapes covering the sign nibble, the leading zero
2442/// of an even digit count, a zero value, thirty-one digits and
2443/// alignment one — and the same bytes in both byte orders. JacORB 3.9
2444/// agrees on eight of the nine, differing only where Java's
2445/// `BigDecimal` has already lost the declared digit count. The run is
2446/// in `crates/ast-layout/tests/fixtures/vendor_bytes.md`, "Where the
2447/// three cannot answer".
2448///
2449/// The form written here is CORBA's, [CDR] §9.3.2.8, which is where an
2450/// IDL `fixed` came from and the only wire definition it has: `P` digit
2451/// nibbles most-significant first, then a sign nibble — `0xc` for
2452/// positive or zero, `0xd` for negative — with a leading zero nibble
2453/// prepended when `P + 1` is odd so the nibbles pair up. That is
2454/// `ceil((P + 1) / 2)` bytes, and it is the extent
2455/// `zerodds_ast_layout` plans for the type, at alignment one: the
2456/// digits are bytes and no padding precedes them.
2457///
2458/// The JSON is a decimal string, because a JSON number is a double and
2459/// a `fixed<31, 2>` is exactly the case a double cannot carry.
2460fn write_fixed<V: Source>(
2461 s: &mut Sink,
2462 digits: u8,
2463 scale: u8,
2464 policy: FixedDigitCount,
2465 value: &V,
2466 path: &str,
2467) -> Result<(), ProbeError> {
2468 if digits == 0 {
2469 return Err(ProbeError::Unsupported {
2470 path: path.to_string(),
2471 kind: "a fixed with no digits, which can hold no value",
2472 });
2473 }
2474 // OMG IDL 4.2 rule (42) caps the BCD rule at 31 digits, and
2475 // `fixed<31, S>` is the widest declaration IDL admits. A wider one
2476 // has no packing defined for it, so it is refused rather than packed
2477 // into octets no reader can be told the width of. The C runtime
2478 // makes the same check on both sides.
2479 if digits > 31 {
2480 return Err(ProbeError::Mismatch {
2481 path: path.to_string(),
2482 expected: "a fixed of at most 31 digits".to_string(),
2483 found: format!("fixed<{digits}, {scale}>"),
2484 });
2485 }
2486 if scale > digits {
2487 return Err(ProbeError::Mismatch {
2488 path: path.to_string(),
2489 expected: "a scale no larger than the digit count".to_string(),
2490 found: format!("fixed<{digits}, {scale}>"),
2491 });
2492 }
2493 let text = contract::text(value, path).map_err(|_| ProbeError::Mismatch {
2494 path: path.to_string(),
2495 expected: "a decimal, as a string — `\"123.45\"`".to_string(),
2496 found: contract::kind_of(value).to_string(),
2497 })?;
2498 s.seen.scalar(path);
2499 // The value's rule and the packing both live in `zerodds-wire`, which is
2500 // where the twenty-first column reaches them too. What stays here is the
2501 // path: `Writer` says what it expected and at which byte, and a refusal
2502 // over a fifty-member document that cannot say which member it was is one
2503 // nobody can act on.
2504 s.w.write_fixed(text, digits, scale, fixed_digits(policy))
2505 .map_err(|error| refused(path, error))
2506}
2507
2508/// The layout planner's digit-count axis as the byte layer's copy of it.
2509///
2510/// `zerodds-wire` does not depend on `zerodds-ast-layout` and must not — a
2511/// byte layer that needs the layout planner is not a byte layer — so the axis
2512/// exists twice and this is the crossing. `tests/fixed_digits_agree.rs` holds
2513/// the two halves against each other over every argument either can take.
2514pub(crate) const fn fixed_digits(policy: FixedDigitCount) -> zerodds_wire::FixedDigits {
2515 match policy {
2516 FixedDigitCount::Declared => zerodds_wire::FixedDigits::Declared,
2517 FixedDigitCount::ValuePrecision => zerodds_wire::FixedDigits::ValuePrecision,
2518 FixedDigitCount::ValueStringDigits => zerodds_wire::FixedDigits::ValueStringDigits,
2519 }
2520}
2521
2522/// A value the runtime refused, as a probe error that names the path.
2523///
2524/// `Writer` says what it expected and at which byte; what it cannot know
2525/// is where in the sample the value came from, and a refusal without the
2526/// path is one nobody can act on over a fifty-member document.
2527fn refused(path: &str, error: zerodds_wire::WireError) -> ProbeError {
2528 let expected = match error {
2529 zerodds_wire::WireError::OutOfRange { expected, .. } => expected,
2530 other => format!("{other}"),
2531 };
2532 ProbeError::Mismatch {
2533 path: path.to_string(),
2534 expected,
2535 found: "the value the runtime refused".to_string(),
2536 }
2537}
2538
2539/// One primitive, with the range check the dialect makes rather than
2540/// the one `try_from` happens to make.
2541///
2542/// Every narrowing below is exact: `contract::integer_for` has already
2543/// refused a value outside the primitive's range, so nothing here can
2544/// wrap.
2545#[allow(
2546 clippy::cast_possible_truncation,
2547 clippy::cast_precision_loss,
2548 clippy::cast_sign_loss,
2549 clippy::cast_possible_wrap
2550)]
2551fn write_primitive<V: Source>(
2552 s: &mut Sink,
2553 kind: PrimitiveKind,
2554 value: &V,
2555 position: WcharPosition,
2556 path: &str,
2557) -> Result<(), ProbeError> {
2558 match kind {
2559 PrimitiveKind::Boolean => s.w.write_bool(contract::boolean(value, path)?),
2560 PrimitiveKind::Octet | PrimitiveKind::UInt8 => {
2561 s.w.write_u8(contract::integer_for(kind, value, path)? as u8);
2562 }
2563 PrimitiveKind::Int8 => {
2564 s.w.write_i8(contract::integer_for(kind, value, path)? as i8);
2565 }
2566 // `character_for` has already refused a code point the width
2567 // cannot carry, and it did so with the path in hand. The runtime
2568 // refuses the same values, so nothing reaches the `?` today; it
2569 // is there because the runtime is the last thing between a value
2570 // and the wire, and a day when the two stop agreeing has to end
2571 // in a refusal rather than in a byte nobody wrote.
2572 PrimitiveKind::Char => {
2573 let character = contract::character_for(kind, value, path)?;
2574 s.w.write_char(character)
2575 .map_err(|error| refused(path, error))?;
2576 }
2577 PrimitiveKind::WChar => {
2578 let character = contract::character_for(kind, value, path)?;
2579 s.w.write_wchar(character, position)
2580 .map_err(|error| refused(path, error))?;
2581 }
2582 PrimitiveKind::Int16 => {
2583 s.w.write_i16(contract::integer_for(kind, value, path)? as i16);
2584 }
2585 PrimitiveKind::UInt16 => {
2586 s.w.write_u16(contract::integer_for(kind, value, path)? as u16);
2587 }
2588 PrimitiveKind::Int32 => {
2589 s.w.write_i32(contract::integer_for(kind, value, path)? as i32);
2590 }
2591 PrimitiveKind::UInt32 => {
2592 s.w.write_u32(contract::integer_for(kind, value, path)? as u32);
2593 }
2594 PrimitiveKind::Int64 => {
2595 s.w.write_i64(contract::integer_for(kind, value, path)? as i64);
2596 }
2597 PrimitiveKind::UInt64 => {
2598 s.w.write_u64(contract::integer_for(kind, value, path)? as u64);
2599 }
2600 PrimitiveKind::Float32 => s.w.write_f32(contract::float(value, path)? as f32),
2601 PrimitiveKind::Float64 => s.w.write_f64(contract::float(value, path)?),
2602 PrimitiveKind::Float128 => s.w.write_f128(contract::float128(value, path)?),
2603 }
2604 s.seen.scalar(path);
2605 Ok(())
2606}
2607
2608const fn length_code(lc: LengthCode) -> u8 {
2609 match lc {
2610 LengthCode::Lc0 => 0,
2611 LengthCode::Lc1 => 1,
2612 LengthCode::Lc2 => 2,
2613 LengthCode::Lc3 => 3,
2614 LengthCode::Lc4 => 4,
2615 LengthCode::Lc5 => 5,
2616 LengthCode::Lc6 => 6,
2617 LengthCode::Lc7 => 7,
2618 }
2619}